--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 9c739fa940d1bcaefcd5de9a3b461762a7200103
Parents : 5567022
Author : Ivan <e46112d44649266d71fe2193e00a4710>
Signature : T66BB85Valid, signed by author
Date : 2026-07-23T08:15:55-05:00
feat: frontend modularity by splitting Vue mega-pages, adding ownership contracts, and updating related documentation
Changes
56 files changed, 5554 insertions(+), 3721 deletions(-)
Diff
diff --git a/Taskfile.yml b/Taskfile.yml
index c1c051ed..e4ed5a0e 100644
--- a/Taskfile.yml
+++ b/Taskfile.yml
@@ -302,7 +302,7 @@ tasks:
- uv run pytest tests/backend/eect/live/test_lv_ladder.py -m live_validation -k "l0" -q --tb=short
test:quick:fe:
- desc: Frontend regression subset (links, micron preview, stranger banner, interface stats)
+ desc: Frontend regression subset (links, micron preview, stranger banner, interface stats, ownership contracts)
cmds:
- >-
{{.NPM}} exec vitest run
@@ -316,6 +316,9 @@ tasks:
{{.NPM}} exec vitest run
tests/frontend/ConversationViewer.test.js
-t "addStrangerAsContact"
+ - >-
+ {{.NPM}} exec vitest run
+ tests/frontend/frontendOwnershipContract.test.js
test:backend:
aliases: [test:be, test:be:full]
diff --git a/docs/agents/README.md b/docs/agents/README.md
index 291f9535..50d75583 100644
--- a/docs/agents/README.md
+++ b/docs/agents/README.md
@@ -39,6 +39,7 @@ Optional editor rules (if present under `.cursor/rules/` or similar):
| ---------------------------------------------------------------------------- | ------------------------------------------------------ |
| [page-toast-tests](skills/page-toast-tests/SKILL.md) | New pages, toasts, i18n, tests |
| [contribution-registries](skills/contribution-registries/SKILL.md) | Nav, tools, commands, settings search, WS event wiring |
+| [vue-mega-page-split](skills/vue-mega-page-split/SKILL.md) | Extract Vue mega-pages into internal/sections/helpers |
| [meshchat-orchestration-split](skills/meshchat-orchestration-split/SKILL.md) | Extract HTTP/WS from meshchat.py, multi-file scanners |
### Mesh protocols (RNS / LXMF / LXST / RRC)
diff --git a/docs/agents/conventions/frontend.md b/docs/agents/conventions/frontend.md
index d1f5dbd9..85b5a319 100644
--- a/docs/agents/conventions/frontend.md
+++ b/docs/agents/conventions/frontend.md
@@ -12,3 +12,14 @@ Applies when editing `meshchatx/src/frontend/**/*.{vue,js}`.
- File inputs: prefer broad `accept` for identity keys (`.bin,.key,.identity,application/octet-stream,*/*`). Database restore stays `.zip`.
- Prefer existing MaterialDesignIcon / layout patterns over new design systems.
- No backticks in code comments. Prefer plain words or quoted identifiers.
+
+## Mega-page extracts
+
+When splitting large page shells, follow `docs/agents/skills/vue-mega-page-split/SKILL.md`
+and the Frontend mega-pages table in `docs/agents/module-ownership.md`.
+
+- Mechanical extract only. Move or behaviour change, never both in the same change.
+- Prefer `internal/` or `settings/sections/` for page-private UI. Prefer colocated or `js/<feature>/` for pure helpers.
+- Aim shells toward under about 2000 lines across multiple PRs. Prefer slices of about 150 to 300 lines.
+- Verify with ownership contracts in `tests/frontend/frontendOwnershipContract.test.js` plus focused page tests.
+- Do not treat `task test:quick` alone as enough coverage for map, settings, call, or conversation extracts.
diff --git a/docs/agents/conventions/tests.md b/docs/agents/conventions/tests.md
index 459a46e3..3f30b787 100644
--- a/docs/agents/conventions/tests.md
+++ b/docs/agents/conventions/tests.md
@@ -31,6 +31,15 @@ Full skill: `docs/agents/skills/test-oracles/SKILL.md`.
Path jail filesystem features: `docs/agents/skills/path-jail-local-fs/SKILL.md` and `docs/agents/conventions/path-jail.md`.
Exploratory bug hunting: `docs/agents/skills/exploratory-testing/SKILL.md`.
+## Frontend mega-page ownership contracts
+
+- Ownership inventory: `tests/frontend/fixtures/frontend_mega_page_ownership.json`
+- Symbol continuity: `tests/frontend/fixtures/frontend_symbol_continuity/`
+- Scanner tests: `tests/frontend/frontendOwnershipContract.test.js`
+- Extract workflow: `docs/agents/skills/vue-mega-page-split/SKILL.md`
+- Refresh ownership fixture only when inventory intentionally changes:
+ `UPDATE_FRONTEND_OWNERSHIP=1 pnpm exec vitest run tests/frontend/frontendOwnershipContract.test.js -t ownership_fixture`
+
## HTTP and WS path-scanning contracts
Route and WS manifests are discovered from source text:
diff --git a/docs/agents/module-ownership.md b/docs/agents/module-ownership.md
index a564f70f..952d0d3e 100644
--- a/docs/agents/module-ownership.md
+++ b/docs/agents/module-ownership.md
@@ -47,14 +47,33 @@ WS inbound handlers live in `ws/handlers_*.py` and are composed by `ws/dispatch.
## Frontend mega-pages
-| Page shell | Child dirs | Shared JS | Primary tests |
-| --------------------------------- | ------------------------------------------------------------------------ | ------------------------------- | -------------------------------------------------------------------------------- |
-| `settings/SettingsPage.vue` | `settings/sections/`, `SettingToggleRow.vue`, `SettingsSectionBlock.vue` | `js/settings/` | `SettingsPage*.test.js`, `settingsSections.test.js`, `behaviorContracts.test.js` |
-| `messages/ConversationViewer.vue` | `messages/composer/`, `modals/`, `outbound/`, `lxmf/` | `conversationMessageHelpers.js` | ConversationViewer tests |
-| `call/CallPage.vue` | `call/tabs/`, `call/audio/` | call helpers | `CallPage*.test.js` |
-| `map/MapPage.vue` | `map/internal/` | `js/map*` | map component tests |
-| `relay/RelayChatPage.vue` | relay view components | `js/relay*` | relay tests |
-| `App.vue` | `layout/` | registries, WS shell | `AppIdentitySwitch.test.js` |
+Placement for extracts. Follow `docs/agents/skills/vue-mega-page-split/SKILL.md`.
+Do not invent folders outside this table.
+
+| Kind | Put it here | Example |
+| --------------------------- | --------------------------------------------------- | ----------------------------------- |
+| Page-private panel or UI | `components/<feature>/internal/*.vue` | MapSearchBar |
+| Settings chunk | `components/settings/sections/*SettingsSection.vue` | TelephonySettingsSection |
+| Pure logic | colocated `*.js` or `js/<feature>/` | clusterUtils, settingsConfigService |
+| Cross-feature primitive | root `components/` or `components/forms/` | ConfirmDialog, Toggle |
+| Nav, tools, commands wiring | `js/registries/` only | never grow App.vue for discovery |
+
+| Page shell | On-disk child dirs | Planned child dirs | Shared JS | Primary tests |
+| ------------------------------------------ | ------------------------------------------------------------------- | -------------------------------------------- | ------------------------------- | -------------------------------------------------------------------------------- |
+| `settings/SettingsPage.vue` | `settings/sections/` (incl. Battery), toggle/block helpers | more sections from remaining inline template | `js/settings/` | `SettingsPage*.test.js`, `settingsSections.test.js`, `behaviorContracts.test.js` |
+| `messages/ConversationViewer.vue` | `composer/`, `modals/`, `outbound/`, `lxmf/`, telemetry, helpers | further script slices | `conversationMessageHelpers.js` | ConversationViewer tests |
+| `call/CallPage.vue` | `call/tabs/`, `call/audio/`, `CallOverlay.vue` | more tabs and audio panels | call helpers | `CallPage*.test.js` |
+| `map/MapPage.vue` | `map/internal/` (panels + helpers, incl. MapSaveDrawingModal) | more overlays and modals into `internal/` | `js/map*` | map component tests |
+| `relay/RelayChatPage.vue` | relay view components | further relay panels as needed | `js/relay*` | relay tests |
+| `App.vue` | `layout/AppShellBanners.vue`, `layout/AppIdentitySwitchOverlay.vue` | more shell chrome under `layout/` | registries, WS shell | `AppIdentitySwitch.test.js` |
+| `network-visualiser/NetworkVisualiser.vue` | `network-visualiser/internal/` | more chrome into `internal/` | `js/networkVisualiser*` | network visualiser tests |
+| `interfaces/AddInterfacePage.vue` | `interfaces/internal/AddInterfaceDiscoveryPanel.vue` | further interface form panels | `js/interfaceDiscoveryUtils.js` | AddInterface tests |
+
+Frontend ownership contracts:
+
+- `tests/frontend/fixtures/frontend_mega_page_ownership.json`
+- `tests/frontend/fixtures/frontend_symbol_continuity/`
+- `tests/frontend/frontendOwnershipContract.test.js`
## Contract ownership
diff --git a/docs/agents/skills/vue-mega-page-split/SKILL.md b/docs/agents/skills/vue-mega-page-split/SKILL.md
new file mode 100644
index 00000000..1748d9bf
--- /dev/null
+++ b/docs/agents/skills/vue-mega-page-split/SKILL.md
@@ -0,0 +1,144 @@
+# Skill: vue-mega-page-split
+
+Extract or split Vue mega-page shells under `meshchatx/src/frontend/components/`
+without changing behaviour.
+
+## When to use
+
+- Splitting listed mega-pages from `docs/agents/module-ownership.md`
+- Moving presentational panels into `internal/` or `settings/sections/`
+- Extracting pure helpers next to a page or under `js/<feature>/`
+
+Not for greenfield pages. Use `page-toast-tests` for new routes and nav wiring.
+
+Also read:
+
+- `docs/agents/module-ownership.md` (Frontend mega-pages)
+- `docs/agents/conventions/frontend.md`
+- `docs/agents/conventions/tests.md`
+- `docs/agents/skills/test-loop/SKILL.md`
+
+## Hard rules
+
+1. Mechanical extract only. No renames, no toast or i18n churn, no API path changes in the same change as a move.
+2. One concern per change: move or behaviour, never both.
+3. Match the Options API style of the parent file. Do not introduce Composition API, provide or inject, Pinia, or a composables tree in an extract.
+4. Follow inventory names in `docs/agents/module-ownership.md`. Do not invent alternate folders.
+5. Shell stays orchestration. Data ownership, `window.api`, map or canvas lifecycle init and teardown, and multi-child toast firing stay on the page shell unless a later behaviour change explicitly moves them.
+6. One slice per PR or commit series. Prefer one panel, one settings section, or one pure helper module.
+7. Promote to shared root components only when two or more unrelated features need the same UI.
+8. Keep SPDX on new project files (`0BSD` unless the file already differs).
+9. Do not edit locale files in a mechanical extract.
+
+## Placement
+
+| Kind | Put it here | Example |
+| --------------------------- | --------------------------------------------------- | ----------------------------------- |
+| Page-private panel or UI | `components/<feature>/internal/*.vue` | MapSearchBar |
+| Settings chunk | `components/settings/sections/*SettingsSection.vue` | TelephonySettingsSection |
+| Pure logic | colocated `*.js` or `js/<feature>/` | clusterUtils, settingsConfigService |
+| Cross-feature primitive | root `components/` or `components/forms/` | ConfirmDialog, Toggle |
+| Nav, tools, commands wiring | `js/registries/` only | never grow App.vue for discovery |
+
+## Pre-extract inventory (mandatory)
+
+Before editing, list:
+
+- Method, computed, and data keys touched by the slice
+- Template markers (`$t` keys, `ref=` names, handler names, `v-if` flags)
+- `/api/v1/...` path fragments and `window.api` usages in the slice
+- External imports the slice needs
+- Existing tests that mount the parent or assert those strings
+
+Inventing symbols not in the inventory is a fail.
+
+## Cut order inside a shell
+
+1. Pure JS first (no `this`, no template). Unit-test like `MapInternalHelpers.test.js`.
+2. Presentational panels next (props in, events out). Match `settings/sections/`.
+3. Stateful feature chunks last. Keep Leaflet, WebGL, and WS lifecycle on the shell until the boundary is obvious.
+
+## Vue cut recipe
+
+1. Identify a contiguous template region plus its methods, computed, and data.
+2. Create the child with SPDX header, Options API, props and emits only for the boundary.
+3. Move markup and methods verbatim. Keep names.
+4. Wire parent import, `components`, and thin wrappers if parent tests still call old method names.
+5. Add or adjust focused tests for the child. Keep parent smoke tests green.
+6. Run continuity checks and focused vitest.
+
+## Size budgets
+
+- Prefer extracting when a contiguous concern is about 150 to 300 lines or a whole settings section.
+- Aim shells toward under about 2000 lines across multiple PRs.
+- Stop a PR when the review diff exceeds roughly one concern (about 300 to 800 lines moved).
+
+## Diff continuity checklist
+
+After the move:
+
+1. `git diff --stat` shows LOC moved, not vanished.
+2. Every inventory method name still hits under the feature tree.
+3. Every inventory `$t` key still exists under the feature tree.
+4. No route, nav, or registry edits unless the slice explicitly required them.
+5. No locale file edits.
+6. Parent registers new children. Template uses them. Old inline markup is removed only for the sliced region.
+
+## Hallucination tripwires (stop-ship)
+
+- New public methods or renamed handlers for cleanliness
+- New provide or inject, Pinia, or composables without an explicit behaviour ticket
+- Child calling `window.api` when the parent previously owned that call, unless the inventory moved the whole call site and tests cover it
+- Deleted emits, props, or refs that tests or the parent still need
+- Folders not listed in ownership
+- Hardcoded English replacing `$t(...)`
+- Updating ownership fixtures only to make a red test green
+
+## Contract scanners
+
+Fixtures:
+
+- `tests/frontend/fixtures/frontend_mega_page_ownership.json`
+- `tests/frontend/fixtures/frontend_symbol_continuity/<page>.json`
+
+Tests:
+
+- `tests/frontend/frontendOwnershipContract.test.js`
+- helpers under `tests/frontend/helpers/`
+
+Refresh ownership fixture only when inventory intentionally changes:
+
+```bash
+UPDATE_FRONTEND_OWNERSHIP=1 pnpm exec vitest run tests/frontend/frontendOwnershipContract.test.js -t ownership_fixture
+```
+
+## Verification
+
+Per slice:
+
+```bash
+pnpm exec eslint <touched files> --fix
+pnpm exec vitest run tests/frontend/<Parent>*.test.js \
+ tests/frontend/<SliceOrHelper>*.test.js \
+ tests/frontend/frontendOwnershipContract.test.js \
+ tests/frontend/behaviorContracts.test.js
+```
+
+Per milestone (meaningful shell shrink or before merge):
+
+```bash
+task test:frontend
+```
+
+Do not treat `task test:quick` alone as sufficient for map, settings, call, or conversation extracts.
+`task test:quick:fe` includes `frontendOwnershipContract.test.js` as a gate, not full page coverage.
+
+## Workflow card
+
+1. Read this skill and the ownership row for the page.
+2. Build the pre-extract inventory.
+3. Mechanical move only into an allowed directory.
+4. Continuity `rg` plus ownership contract tests.
+5. Focused vitest for parent, new files, and `behaviorContracts`.
+6. Eslint on touched files.
+7. Milestone: `task test:frontend`.
diff --git a/meshchatx.rsm b/meshchatx.rsm
index fa92840b..6c63c443 100644
Binary files a/meshchatx.rsm and b/meshchatx.rsm differ
diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue
index 7fbf59f2..6465c673 100644
--- a/meshchatx/src/frontend/components/App.vue
+++ b/meshchatx/src/frontend/components/App.vue
@@ -581,42 +581,7 @@
</div>
<!-- identity switching overlay -->
- <transition name="fade-blur">
- <div
- v-if="isSwitchingIdentity"
- class="fixed inset-0 z-200 flex items-center justify-center bg-slate-900/45 dark:bg-black/55 backdrop-blur-xs px-4"
- role="status"
- aria-live="polite"
- >
- <div
- class="w-full max-w-sm overflow-hidden rounded-2xl border border-gray-200/90 bg-white/95 shadow-xl dark:border-zinc-700/90 dark:bg-zinc-900/95"
- >
- <div class="px-6 pt-7 pb-1 text-center">
- <div
- class="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-slate-50 ring-1 ring-gray-200/80 dark:bg-zinc-950 dark:ring-zinc-700"
- >
- <img :src="logoUrl" alt="" class="h-9 w-9 object-contain p-1" />
- </div>
- <p class="text-xs font-medium text-gray-500 dark:text-zinc-500">
- {{ $t("app.loading_overlay_kicker") }}
- </p>
- <h2 class="mt-1.5 text-lg font-semibold tracking-tight text-gray-900 dark:text-white">
- {{ $t("app.loading_overlay_title") }}
- </h2>
- <p class="mt-3 text-sm leading-relaxed text-gray-600 dark:text-zinc-400">
- {{ $t("app.loading_overlay_subtitle") }}
- </p>
- </div>
- <div class="px-6 pb-7 pt-2">
- <div class="h-1 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-zinc-800">
- <div
- class="identity-switch-indeterminate h-full w-1/3 rounded-full bg-blue-600 dark:bg-blue-500"
- />
- </div>
- </div>
- </div>
- </div>
- </transition>
+ <AppIdentitySwitchOverlay :show="isSwitchingIdentity" :logo-url="logoUrl" />
</div>
</template>
@@ -664,6 +629,7 @@ import TutorialModal from "./TutorialModal.vue";
import AndroidStorageChoicePrompt from "./AndroidStorageChoicePrompt.vue";
import PostInstallPromptHost from "./PostInstallPromptHost.vue";
import AppShellBanners from "./layout/AppShellBanners.vue";
+import AppIdentitySwitchOverlay from "./layout/AppIdentitySwitchOverlay.vue";
import KeyboardShortcuts from "../js/KeyboardShortcuts";
import ElectronUtils from "../js/ElectronUtils";
import { postRequestPath } from "../js/reticulumPathfinding.js";
@@ -700,6 +666,7 @@ export default {
AndroidStorageChoicePrompt,
PostInstallPromptHost,
AppShellBanners,
+ AppIdentitySwitchOverlay,
},
setup() {
const vuetifyTheme = useTheme();
@@ -2505,17 +2472,4 @@ export default {
opacity: 0;
backdrop-filter: blur(0);
}
-
-@keyframes identity-switch-indeterminate {
- 0% {
- transform: translateX(-100%);
- }
- 100% {
- transform: translateX(350%);
- }
-}
-
-.identity-switch-indeterminate {
- animation: identity-switch-indeterminate 1.4s ease-in-out infinite;
-}
</style>
diff --git a/meshchatx/src/frontend/components/call/CallPage.vue b/meshchatx/src/frontend/components/call/CallPage.vue
index 79f4bf69..deca93e9 100644
--- a/meshchatx/src/frontend/components/call/CallPage.vue
+++ b/meshchatx/src/frontend/components/call/CallPage.vue
@@ -5,80 +5,11 @@
<div class="flex h-full w-full flex-col bg-gray-100 dark:bg-zinc-950">
<div class="w-full h-full overflow-y-auto">
<div class="mx-auto w-full max-w-4xl p-4 md:p-6 flex-1 flex flex-col min-h-full">
- <!-- Tabs -->
- <div class="flex flex-wrap justify-center border-b border-gray-200 dark:border-zinc-800 shrink-0">
- <button
- :class="[
- activeTab === 'phone'
- ? 'border-blue-500 text-blue-600 dark:text-blue-400'
- : 'border-transparent text-gray-500 hover:text-gray-700 dark:text-zinc-400 dark:hover:text-zinc-200 hover:border-gray-300',
- ]"
- class="py-2 px-4 border-b-2 font-medium text-sm transition-all"
- @click="activeTab = 'phone'"
- >
- Phone
- </button>
- <button
- :class="[
- activeTab === 'phonebook'
- ? 'border-blue-500 text-blue-600 dark:text-blue-400'
- : 'border-transparent text-gray-500 hover:text-gray-700 dark:text-zinc-400 dark:hover:text-zinc-200 hover:border-gray-300',
- ]"
- class="py-2 px-4 border-b-2 font-medium text-sm transition-all"
- @click="activeTab = 'phonebook'"
- >
- Phonebook
- </button>
- <button
- :class="[
- activeTab === 'voicemail'
- ? 'border-blue-500 text-blue-600 dark:text-blue-400'
- : 'border-transparent text-gray-500 hover:text-gray-700 dark:text-zinc-400 dark:hover:text-zinc-200 hover:border-gray-300',
- ]"
- class="py-2 px-4 border-b-2 font-medium text-sm flex items-center gap-2 transition-all"
- @click="activeTab = 'voicemail'"
- >
- Voicemail
- <span
- v-if="unreadVoicemailsCount > 0"
- class="bg-red-500 text-white text-[10px] px-1.5 py-0.5 rounded-full animate-pulse"
- >{{ unreadVoicemailsCount }}</span
- >
- </button>
- <button
- :class="[
- activeTab === 'contacts'
- ? 'border-blue-500 text-blue-600 dark:text-blue-400'
- : 'border-transparent text-gray-500 hover:text-gray-700 dark:text-zinc-400 dark:hover:text-zinc-200 hover:border-gray-300',
- ]"
- class="py-2 px-4 border-b-2 font-medium text-sm transition-all"
- @click="activeTab = 'contacts'"
- >
- Contacts
- </button>
- <button
- :class="[
- activeTab === 'ringtone'
- ? 'border-blue-500 text-blue-600 dark:text-blue-400'
- : 'border-transparent text-gray-500 hover:text-gray-700 dark:text-zinc-400 dark:hover:text-zinc-200 hover:border-gray-300',
- ]"
- class="py-2 px-4 border-b-2 font-medium text-sm transition-all"
- @click="activeTab = 'ringtone'"
- >
- {{ $t("call.ringtone") }}
- </button>
- <!-- <button
- :class="[
- activeTab === 'recordings'
- ? 'border-blue-500 text-blue-600 dark:text-blue-400'
- : 'border-transparent text-gray-500 hover:text-gray-700 dark:text-zinc-400 dark:hover:text-zinc-200 hover:border-gray-300',
- ]"
- class="py-2 px-4 border-b-2 font-medium text-sm transition-all"
- @click="activeTab = 'recordings'"
- >
- {{ $t("call.recordings") }}
- </button> -->
- </div>
+ <CallTabBar
+ :active-tab="activeTab"
+ :unread-voicemails-count="unreadVoicemailsCount"
+ @update:active-tab="activeTab = $event"
+ />
<!-- Phone Tab -->
<div v-if="activeTab === 'phone'" class="flex-1 flex flex-col pt-2">
@@ -1120,712 +1051,64 @@
</div>
<!-- Phonebook Tab -->
- <div v-if="activeTab === 'phonebook'" class="flex-1 flex flex-col max-w-3xl mx-auto w-full pt-2">
- <div class="mb-4">
- <div class="relative">
- <input
- v-model="discoverySearch"
- type="text"
- :placeholder="`Search phonebook (${totalDiscoveryCount})...`"
- class="block w-full rounded-lg border-0 py-2 pl-10 text-gray-900 dark:text-white shadow-xs ring-1 ring-inset ring-gray-300 dark:ring-zinc-800 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm dark:bg-zinc-900"
- @input="onDiscoverySearchInput"
- />
- <div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
- <MaterialDesignIcon icon-name="magnify" class="size-5 text-gray-400" />
- </div>
- </div>
- </div>
-
- <div v-if="discoveryAnnounces.length === 0" class="my-auto text-center">
- <div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-full inline-block mb-4">
- <MaterialDesignIcon icon-name="satellite-uplink" class="size-12 text-gray-400" />
- </div>
- <h3 class="text-lg font-medium text-gray-900 dark:text-white">No Telephony Peers</h3>
- <p class="text-gray-500 dark:text-zinc-400">Waiting for announces on the mesh.</p>
- </div>
-
- <div v-else class="space-y-4">
- <div class="border-b border-gray-200 dark:border-zinc-800 overflow-hidden">
- <ul class="divide-y divide-gray-100 dark:divide-zinc-800">
- <li
- v-for="announce in discoveryAnnounces"
- :key="announce.destination_hash"
- class="px-4 py-4 hover:bg-gray-50 dark:hover:bg-zinc-800/50 transition-colors"
- >
- <div class="flex items-center space-x-4">
- <div class="shrink-0">
- <LxmfUserIcon
- :custom-image="announce.contact_image"
- :icon-name="announce.lxmf_user_icon?.icon_name"
- :icon-foreground-colour="announce.lxmf_user_icon?.foreground_colour"
- :icon-background-colour="announce.lxmf_user_icon?.background_colour"
- class="size-10"
- />
- </div>
- <div class="flex-1 min-w-0">
- <div class="flex items-center justify-between">
- <div class="flex items-center min-w-0">
- <p
- class="text-sm font-bold text-gray-900 dark:text-white truncate"
- >
- {{ announce.display_name || "Anonymous Peer" }}
- </p>
- <a
- v-if="announce.lxmf_destination_hash"
- :href="`/#/messages/${announce.lxmf_destination_hash}`"
- class="ml-2 p-1 text-gray-400 hover:text-blue-500 transition-colors"
- title="Message via LXMF"
- @click.stop
- >
- <MaterialDesignIcon
- icon-name="message-text-outline"
- class="size-4"
- />
- </a>
- </div>
- <span
- class="text-[10px] text-gray-500 dark:text-zinc-500 font-mono ml-2 shrink-0"
- >
- {{ formatTimeAgo(announce.updated_at) }}
- </span>
- </div>
- <div class="flex items-center justify-between mt-1">
- <div class="flex items-center space-x-2 min-w-0">
- <span
- class="text-[10px] text-gray-500 dark:text-zinc-500 font-mono truncate cursor-pointer hover:text-blue-500 transition-colors"
- :title="announce.destination_hash"
- @click.stop="copyHash(announce.destination_hash)"
- >
- {{ formatDestinationHash(announce.destination_hash) }}
- </span>
- <span
- v-if="announce.hops != null"
- class="text-[10px] text-gray-400 dark:text-zinc-600"
- >
- • {{ announce.hops }} hops
- </span>
- </div>
- <button
- type="button"
- class="text-[10px] bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400 px-3 py-1 rounded-full font-bold uppercase tracking-wider hover:bg-blue-200 dark:hover:bg-blue-900/50 transition-colors shrink-0"
- @click="
- destinationHash = announce.destination_hash;
- activeTab = 'phone';
- $nextTick(() => call(destinationHash));
- "
- >
- Call
- </button>
- </div>
- </div>
- </div>
- </li>
- </ul>
- <div
- v-if="hasMoreDiscovery"
- class="p-3 border-t border-gray-100 dark:border-zinc-800 text-center"
- >
- <button
- type="button"
- class="text-xs text-blue-500 hover:text-blue-600 font-bold uppercase tracking-widest"
- @click="loadMoreDiscovery"
- >
- {{ $t("call.load_more") }}
- </button>
- </div>
- </div>
- </div>
- </div>
-
- <!-- Voicemail Tab -->
- <div v-if="activeTab === 'voicemail'" class="flex-1 flex flex-col max-w-3xl mx-auto w-full pt-2">
- <div class="mb-4">
- <div class="relative">
- <input
- v-model="voicemailSearch"
- type="text"
- placeholder="Search voicemails..."
- class="block w-full rounded-lg border-0 py-2 pl-10 text-gray-900 dark:text-white shadow-xs ring-1 ring-inset ring-gray-300 dark:ring-zinc-800 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm dark:bg-zinc-900"
- @input="onVoicemailSearchInput"
- />
- <div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
- <MaterialDesignIcon icon-name="magnify" class="size-5 text-gray-400" />
- </div>
- </div>
- </div>
-
- <!-- Voicemail Settings Card -->
- <div v-if="config" class="mb-4 border-b border-gray-200 dark:border-zinc-800 overflow-hidden">
- <button
- type="button"
- class="w-full px-4 py-3 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-zinc-800/50 transition-colors"
- @click="isVoicemailSettingsExpanded = !isVoicemailSettingsExpanded"
- >
- <div class="flex items-center gap-2">
- <MaterialDesignIcon icon-name="cog" class="size-5 text-blue-500" />
- <h3
- class="text-sm font-bold text-gray-900 dark:text-white uppercase tracking-wider"
- >
- Voicemail Settings
- </h3>
- </div>
- <MaterialDesignIcon
- :icon-name="isVoicemailSettingsExpanded ? 'chevron-up' : 'chevron-down'"
- class="size-5 text-gray-400"
- />
- </button>
-
- <div v-if="isVoicemailSettingsExpanded" class="px-4 pb-6 space-y-6">
- <!-- Status Banner -->
- <div
- v-if="!voicemailStatus.has_espeak"
- class="p-4 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg flex gap-3 items-start"
- >
- <MaterialDesignIcon
- icon-name="alert"
- class="size-5 text-amber-600 dark:text-amber-400 shrink-0"
- />
- <div class="text-xs text-amber-800 dark:text-amber-200">
- <p class="font-bold mb-1">Dependencies Missing</p>
- <p>
- Voicemail requires `espeak-ng` to generate greetings. Please install it on
- your system.
- </p>
- </div>
- </div>
-
- <!-- Enabled Toggle -->
- <div class="flex items-center justify-between">
- <div>
- <div class="text-sm font-semibold text-gray-900 dark:text-white">
- Enable Voicemail
- </div>
- <div class="text-xs text-gray-500 dark:text-zinc-400">
- Accept calls automatically and record messages
- </div>
- </div>
- <button
- :disabled="!voicemailStatus.has_espeak"
- class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-hidden disabled:opacity-50 disabled:cursor-not-allowed"
- :class="
- config.voicemail_enabled ? 'bg-blue-600' : 'bg-gray-200 dark:bg-zinc-700'
- "
- @click="
- config.voicemail_enabled = !config.voicemail_enabled;
- updateConfig({ voicemail_enabled: config.voicemail_enabled });
- "
- >
- <span
- class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow-sm ring-0 transition duration-200 ease-in-out"
- :class="config.voicemail_enabled ? 'translate-x-5' : 'translate-x-0'"
- ></span>
- </button>
- </div>
-
- <!-- Greeting Text -->
- <div class="space-y-2">
- <label
- class="text-xs font-bold text-gray-500 dark:text-zinc-400 uppercase tracking-tighter"
- >Greeting Message</label
- >
- <textarea
- v-model="config.voicemail_greeting"
- rows="3"
- class="block w-full rounded-lg border-0 py-2 text-gray-900 dark:text-white shadow-xs ring-1 ring-inset ring-gray-300 dark:ring-zinc-800 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6 dark:bg-zinc-900"
- placeholder="Enter greeting text..."
- ></textarea>
-
- <!-- TTS Settings -->
- <div class="grid grid-cols-2 gap-3 mt-2">
- <div class="space-y-1">
- <label
- class="text-[10px] font-bold text-gray-500 dark:text-zinc-500 uppercase tracking-tighter"
- >{{ $t("call.tts_speed") }}</label
- >
- <input
- v-model.number="config.voicemail_tts_speed"
- type="number"
- min="80"
- max="450"
- class="block w-full rounded-lg border-0 py-1 text-gray-900 dark:text-white shadow-xs ring-1 ring-inset ring-gray-300 dark:ring-zinc-800 focus:ring-2 focus:ring-inset focus:ring-blue-600 text-xs dark:bg-zinc-900"
- @change="
- updateConfig({ voicemail_tts_speed: config.voicemail_tts_speed })
- "
- />
- </div>
- <div class="space-y-1">
- <label
- class="text-[10px] font-bold text-gray-500 dark:text-zinc-500 uppercase tracking-tighter"
- >{{ $t("call.tts_pitch") }}</label
- >
- <input
- v-model.number="config.voicemail_tts_pitch"
- type="number"
- min="0"
- max="99"
- class="block w-full rounded-lg border-0 py-1 text-gray-900 dark:text-white shadow-xs ring-1 ring-inset ring-gray-300 dark:ring-zinc-800 focus:ring-2 focus:ring-inset focus:ring-blue-600 text-xs dark:bg-zinc-900"
- @change="
- updateConfig({ voicemail_tts_pitch: config.voicemail_tts_pitch })
- "
- />
- </div>
- <div class="space-y-1">
- <label
- class="text-[10px] font-bold text-gray-500 dark:text-zinc-500 uppercase tracking-tighter"
- >{{ $t("call.tts_word_gap") }}</label
- >
- <input
- v-model.number="config.voicemail_tts_word_gap"
- type="number"
- min="0"
- max="100"
- class="block w-full rounded-lg border-0 py-1 text-gray-900 dark:text-white shadow-xs ring-1 ring-inset ring-gray-300 dark:ring-zinc-800 focus:ring-2 focus:ring-inset focus:ring-blue-600 text-xs dark:bg-zinc-900"
- @change="
- updateConfig({
- voicemail_tts_word_gap: config.voicemail_tts_word_gap,
- })
- "
- />
- </div>
- <div class="space-y-1">
- <label
- class="text-[10px] font-bold text-gray-500 dark:text-zinc-500 uppercase tracking-tighter"
- >{{ $t("call.tts_voice") }}</label
- >
- <input
- v-model="config.voicemail_tts_voice"
- type="text"
- class="block w-full rounded-lg border-0 py-1 text-gray-900 dark:text-white shadow-xs ring-1 ring-inset ring-gray-300 dark:ring-zinc-800 focus:ring-2 focus:ring-inset focus:ring-blue-600 text-xs dark:bg-zinc-900"
- @change="
- updateConfig({ voicemail_tts_voice: config.voicemail_tts_voice })
- "
- />
- </div>
- </div>
-
- <div class="flex justify-between items-center">
- <p class="text-[10px] text-gray-500 dark:text-zinc-500">
- This text will be converted to speech using eSpeak NG.
- </p>
- <div class="flex gap-2">
- <button
- :disabled="!voicemailStatus.has_espeak || isGeneratingGreeting"
- class="text-[10px] bg-gray-100 dark:bg-zinc-800 text-gray-700 dark:text-zinc-300 px-3 py-1 rounded-full font-bold hover:bg-gray-200 dark:hover:bg-zinc-700 transition-colors disabled:opacity-50"
- @click="
- updateConfig({ voicemail_greeting: config.voicemail_greeting });
- generateGreeting();
- "
- >
- {{ isGeneratingGreeting ? "Generating..." : "Save & Generate" }}
- </button>
- </div>
- </div>
- </div>
-
- <!-- Custom Greeting Upload -->
- <div class="space-y-2">
- <label
- class="text-xs font-bold text-gray-500 dark:text-zinc-400 uppercase tracking-tighter"
- >Custom Audio Greeting</label
- >
- <div class="flex items-center gap-3 flex-wrap">
- <input
- ref="greetingUpload"
- type="file"
- accept="audio/*"
- class="hidden"
- @change="uploadGreeting"
- />
- <button
- :disabled="isUploadingGreeting || voicemailStatus.is_greeting_recording"
- class="text-xs bg-gray-100 dark:bg-zinc-800 text-gray-700 dark:text-zinc-300 px-4 py-2 rounded-lg font-bold hover:bg-gray-200 dark:hover:bg-zinc-700 transition-colors disabled:opacity-50 flex items-center gap-2"
- @click="$refs.greetingUpload.click()"
- >
- <MaterialDesignIcon icon-name="upload" class="size-4" />
- {{ isUploadingGreeting ? "Uploading..." : "Upload Audio File" }}
- </button>
- <button
- class="text-xs px-4 py-2 rounded-lg font-bold transition-colors flex items-center gap-2"
- :class="
- voicemailStatus.is_greeting_recording
- ? 'bg-red-500 text-white animate-pulse'
- : 'bg-gray-100 dark:bg-zinc-800 text-gray-700 dark:text-zinc-300 hover:bg-gray-200 dark:hover:bg-zinc-700'
- "
- @click="
- voicemailStatus.is_greeting_recording
- ? stopRecordingGreetingMic()
- : startRecordingGreetingMic()
- "
- >
- <MaterialDesignIcon
- :icon-name="
- voicemailStatus.is_greeting_recording ? 'stop' : 'microphone'
- "
- class="size-4"
- />
- {{
- voicemailStatus.is_greeting_recording
- ? "Stop Recording"
- : "Record from Mic"
- }}
- </button>
-
- <div v-if="voicemailStatus.has_greeting" class="flex items-center gap-2">
- <button
- class="text-xs bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 px-4 py-2 rounded-lg font-bold hover:bg-red-200 dark:hover:bg-red-900/50 transition-colors flex items-center gap-2"
- @click="deleteGreeting"
- >
- <MaterialDesignIcon icon-name="delete" class="size-4" />
- Remove Greeting
- </button>
- <button
- class="text-xs bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 px-4 py-2 rounded-lg font-bold hover:bg-blue-200 dark:hover:bg-blue-900/50 transition-colors flex items-center gap-2"
- @click="playGreeting"
- >
- <MaterialDesignIcon
- :icon-name="isPlayingGreeting ? 'stop' : 'play'"
- class="size-4"
- />
- {{ isPlayingGreeting ? "Stop Preview" : "Preview" }}
- </button>
- </div>
- <div v-else class="text-[10px] text-gray-500 dark:text-zinc-500 italic">
- No custom greeting uploaded (default text will be used)
- </div>
- </div>
- <p class="text-[10px] text-gray-500 dark:text-zinc-500">
- Supports MP3, OGG, WAV, M4A, FLAC. Will be converted to Opus.
- </p>
- </div>
-
- <!-- Delays -->
- <div class="grid grid-cols-2 gap-4">
- <div class="space-y-2">
- <label
- class="text-xs font-bold text-gray-500 dark:text-zinc-400 uppercase tracking-tighter"
- >Answer Delay (s)</label
- >
- <input
- v-model.number="config.voicemail_auto_answer_delay_seconds"
- type="number"
- min="1"
- max="120"
- class="block w-full rounded-lg border-0 py-1.5 text-gray-900 dark:text-white shadow-xs ring-1 ring-inset ring-gray-300 dark:ring-zinc-800 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm dark:bg-zinc-900"
- @change="
- updateConfig({
- voicemail_auto_answer_delay_seconds:
- config.voicemail_auto_answer_delay_seconds,
- })
- "
- />
- </div>
- <div class="space-y-2">
- <label
- class="text-xs font-bold text-gray-500 dark:text-zinc-400 uppercase tracking-tighter"
- >Max Recording (s)</label
- >
- <input
- v-model.number="config.voicemail_max_recording_seconds"
- type="number"
- min="5"
- max="600"
- class="block w-full rounded-lg border-0 py-1.5 text-gray-900 dark:text-white shadow-xs ring-1 ring-inset ring-gray-300 dark:ring-zinc-800 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm dark:bg-zinc-900"
- @change="
- updateConfig({
- voicemail_max_recording_seconds:
- config.voicemail_max_recording_seconds,
- })
- "
- />
- </div>
- </div>
- </div>
- </div>
-
- <div v-if="voicemails.length === 0" class="my-auto text-center">
- <div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-full inline-block mb-4">
- <MaterialDesignIcon icon-name="voicemail" class="size-12 text-gray-400" />
- </div>
- <h3 class="text-lg font-medium text-gray-900 dark:text-white">No Voicemails</h3>
- <p class="text-gray-500 dark:text-zinc-400">
- When people leave you messages, they'll show up here.
- </p>
- </div>
-
- <div v-else class="space-y-4">
- <div class="border-b border-gray-200 dark:border-zinc-800 overflow-hidden">
- <div
- class="px-4 py-3 border-b border-gray-200 dark:border-zinc-800 flex justify-between items-center"
- >
- <h3
- class="text-sm font-bold text-gray-900 dark:text-white uppercase tracking-wider"
- >
- Voicemail Inbox
- </h3>
- <span
- class="text-[10px] bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400 px-2 py-0.5 rounded-full font-bold uppercase"
- >
- {{ voicemails.length }} Messages
- </span>
- </div>
- <ul class="divide-y divide-gray-100 dark:divide-zinc-800">
- <li
- v-for="voicemail in voicemails"
- :key="voicemail.id"
- class="px-4 py-4 hover:bg-gray-50 dark:hover:bg-zinc-800/50 transition-colors"
- :class="{ 'bg-blue-50/50 dark:bg-blue-900/10': !voicemail.is_read }"
- >
- <div class="flex items-start space-x-4">
- <!-- Icon / Play/Pause Button -->
- <div class="relative shrink-0">
- <LxmfUserIcon
- :custom-image="
- getContactByHash(voicemail.remote_identity_hash)?.custom_image
- "
- :icon-name="
- voicemail.remote_icon ? voicemail.remote_icon.icon_name : ''
- "
- :icon-foreground-colour="
- voicemail.remote_icon
- ? voicemail.remote_icon.foreground_colour
- : ''
- "
- :icon-background-colour="
- voicemail.remote_icon
- ? voicemail.remote_icon.background_colour
- : ''
- "
- class="size-10"
- />
- </div>
-
- <div class="flex-1 min-w-0">
- <div class="flex items-center justify-between mb-1">
- <div class="flex items-center min-w-0 mr-2">
- <p
- class="text-sm font-bold text-gray-900 dark:text-white truncate"
- >
- {{ voicemail.remote_identity_name || $t("call.unknown") }}
- </p>
- <span
- v-if="!voicemail.is_read"
- class="ml-2 shrink-0 size-2 inline-block rounded-full bg-blue-500"
- ></span>
- </div>
- <span
- class="text-[10px] text-gray-500 dark:text-zinc-500 font-mono shrink-0"
- >
- {{ formatDateTime(voicemail.timestamp * 1000) }}
- </span>
- </div>
-
- <div
- class="flex items-center text-xs text-gray-500 dark:text-zinc-400 space-x-3 mb-3"
- >
- <span class="flex items-center gap-1">
- <MaterialDesignIcon icon-name="clock-outline" class="size-3" />
- {{ formatDuration(voicemail.duration_seconds) }}
- </span>
- <span
- class="opacity-60 font-mono text-[10px] truncate cursor-pointer hover:text-blue-500 transition-colors"
- :title="voicemail.remote_identity_hash"
- @click.stop="copyHash(voicemail.remote_identity_hash)"
- >{{
- formatDestinationHash(voicemail.remote_identity_hash)
- }}</span
- >
- </div>
-
- <div class="mb-4">
- <AudioWaveformPlayer
- :src="`/api/v1/telephone/voicemails/${voicemail.id}/audio`"
- @play="markVoicemailAsRead(voicemail)"
- />
- </div>
-
- <div class="flex items-center gap-4">
- <button
- type="button"
- class="text-[10px] flex items-center gap-1 text-gray-500 hover:text-blue-500 font-bold uppercase tracking-wider transition-colors"
- @click="
- destinationHash =
- voicemail.remote_telephony_hash ||
- voicemail.remote_destination_hash ||
- voicemail.remote_identity_hash;
- activeTab = 'phone';
- $nextTick(() => call(destinationHash));
- "
- >
- <MaterialDesignIcon icon-name="phone" class="size-3" />
- Call Back
- </button>
- <a
- :href="`/api/v1/telephone/voicemails/${voicemail.id}/audio`"
- :download="`voicemail_${voicemail.id}.opus`"
- class="text-[10px] flex items-center gap-1 text-gray-500 hover:text-blue-500 font-bold uppercase tracking-wider transition-colors"
- >
- <MaterialDesignIcon icon-name="download" class="size-3" />
- Download
- </a>
- <button
- type="button"
- class="text-[10px] flex items-center gap-1 text-red-500 hover:text-red-600 font-bold uppercase tracking-wider transition-colors"
- @click="deleteVoicemail(voicemail.id)"
- >
- <MaterialDesignIcon icon-name="delete" class="size-3" />
- Delete
- </button>
- </div>
- </div>
- </div>
- </li>
- </ul>
- </div>
- </div>
- </div>
+ <CallPhonebookTab
+ :active="activeTab === 'phonebook'"
+ :discovery-search="discoverySearch"
+ :total-discovery-count="totalDiscoveryCount"
+ :discovery-announces="discoveryAnnounces"
+ :has-more-discovery="hasMoreDiscovery"
+ :format-time-ago="formatTimeAgo"
+ :format-destination-hash="formatDestinationHash"
+ @update:discovery-search="discoverySearch = $event"
+ @search-input="onDiscoverySearchInput"
+ @copy-hash="copyHash"
+ @load-more="loadMoreDiscovery"
+ @call="onPhonebookCall"
+ />
+
+ <CallVoicemailTab
+ :active="activeTab === 'voicemail'"
+ :voicemail-search="voicemailSearch"
+ :config="config"
+ :voicemail-status="voicemailStatus"
+ :voicemails="voicemails"
+ :is-generating-greeting="isGeneratingGreeting"
+ :is-uploading-greeting="isUploadingGreeting"
+ :is-playing-greeting="isPlayingGreeting"
+ :get-contact-by-hash="getContactByHash"
+ :format-date-time="formatDateTime"
+ :format-duration="formatDuration"
+ :format-destination-hash="formatDestinationHash"
+ @update:voicemail-search="voicemailSearch = $event"
+ @search-input="onVoicemailSearchInput"
+ @update-config="onVoicemailUpdateConfig"
+ @patch-config="onVoicemailPatchConfig"
+ @save-and-generate="onVoicemailSaveAndGenerate"
+ @upload-greeting="uploadGreeting"
+ @start-recording-greeting="startRecordingGreetingMic"
+ @stop-recording-greeting="stopRecordingGreetingMic"
+ @delete-greeting="deleteGreeting"
+ @play-greeting="playGreeting"
+ @copy-hash="copyHash"
+ @mark-read="markVoicemailAsRead"
+ @call-back="onPhonebookCall"
+ @delete="deleteVoicemail"
+ />
<!-- Contacts Tab -->
- <div v-if="activeTab === 'contacts'" class="flex-1 flex flex-col max-w-3xl mx-auto w-full pt-2">
- <div class="mb-4 flex gap-2">
- <div class="relative flex-1">
- <input
- v-model="contactsSearch"
- type="text"
- placeholder="Search contacts..."
- class="block w-full rounded-lg border-0 py-2 pl-10 text-gray-900 dark:text-white shadow-xs ring-1 ring-inset ring-gray-300 dark:ring-zinc-800 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm dark:bg-zinc-900"
- @input="onContactsSearchInput"
- />
- <div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
- <MaterialDesignIcon icon-name="magnify" class="size-5 text-gray-400" />
- </div>
- </div>
- <button
- type="button"
- class="rounded-lg bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-blue-500 transition-colors flex items-center gap-2"
- @click="openAddContactModal"
- >
- <MaterialDesignIcon icon-name="plus" class="size-5" />
- Add
- </button>
- </div>
-
- <div v-if="contacts.length === 0" class="my-auto text-center">
- <div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-full inline-block mb-4">
- <MaterialDesignIcon icon-name="account-multiple" class="size-12 text-gray-400" />
- </div>
- <h3 class="text-lg font-medium text-gray-900 dark:text-white">No Contacts</h3>
- <p class="text-gray-500 dark:text-zinc-400">Add contacts to quickly call them.</p>
- </div>
-
- <div v-else class="space-y-4">
- <div class="border-b border-gray-200 dark:border-zinc-800 overflow-hidden">
- <ul class="divide-y divide-gray-100 dark:divide-zinc-800">
- <li
- v-for="contact in contacts"
- :key="contact.id"
- class="px-4 py-3 hover:bg-gray-50 dark:hover:bg-zinc-800/50 transition-colors"
- >
- <div class="flex items-center space-x-3">
- <div class="shrink-0">
- <LxmfUserIcon
- :custom-image="contact.custom_image"
- :icon-name="
- contact.remote_icon ? contact.remote_icon.icon_name : ''
- "
- :icon-foreground-colour="
- contact.remote_icon ? contact.remote_icon.foreground_colour : ''
- "
- :icon-background-colour="
- contact.remote_icon ? contact.remote_icon.background_colour : ''
- "
- class="size-10"
- />
- </div>
- <div class="flex-1 min-w-0">
- <div class="flex items-center justify-between">
- <p class="text-sm font-bold text-gray-900 dark:text-white truncate">
- {{ contact.name }}
- </p>
- <div class="flex items-center gap-2">
- <span
- v-if="contact.preferred_ringtone_id"
- class="text-[9px] px-1.5 py-0.5 rounded-sm bg-amber-50 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400 border border-amber-100 dark:border-amber-800/50 flex items-center gap-1"
- title="Custom Ringtone Set"
- >
- <MaterialDesignIcon icon-name="music" class="size-2.5" />
- {{
- contact.preferred_ringtone_id === -1
- ? "Random"
- : "Custom"
- }}
- </span>
- <div class="flex items-center gap-1">
- <button
- type="button"
- class="p-1.5 text-gray-400 hover:text-blue-500 transition-colors"
- @click="openEditContactModal(contact)"
- >
- <MaterialDesignIcon icon-name="pencil" class="size-4" />
- </button>
- <button
- type="button"
- class="p-1.5 text-gray-400 hover:text-red-500 transition-colors"
- @click="deleteContact(contact.id)"
- >
- <MaterialDesignIcon icon-name="delete" class="size-4" />
- </button>
- </div>
- </div>
- </div>
- <div class="flex items-center justify-between mt-1">
- <div class="flex flex-col min-w-0">
- <span
- class="text-[10px] text-gray-500 dark:text-zinc-500 font-mono truncate cursor-pointer hover:text-blue-500 transition-colors"
- :title="contact.remote_identity_hash"
- @click.stop="copyHash(contact.remote_identity_hash)"
- >
- ID:
- {{ formatDestinationHash(contact.remote_identity_hash) }}
- </span>
- <span
- v-if="contact.lxmf_address"
- class="text-[9px] text-gray-400 dark:text-zinc-500 font-mono truncate cursor-pointer hover:text-blue-500 transition-colors"
- :title="contact.lxmf_address"
- @click.stop="copyHash(contact.lxmf_address)"
- >
- LXMF: {{ formatDestinationHash(contact.lxmf_address) }}
- </span>
- <span
- v-if="contact.lxst_address"
- class="text-[9px] text-gray-400 dark:text-zinc-500 font-mono truncate cursor-pointer hover:text-blue-500 transition-colors"
- :title="contact.lxst_address"
- @click.stop="copyHash(contact.lxst_address)"
- >
- LXST: {{ formatDestinationHash(contact.lxst_address) }}
- </span>
- </div>
- <button
- type="button"
- class="text-[10px] bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400 px-3 py-1 rounded-full font-bold uppercase tracking-wider hover:bg-blue-200 dark:hover:bg-blue-900/50 transition-colors shrink-0"
- @click="
- destinationHash =
- contact.remote_telephony_hash ||
- contact.remote_destination_hash ||
- contact.remote_identity_hash;
- activeTab = 'phone';
- $nextTick(() => call(destinationHash));
- "
- >
- Call
- </button>
- </div>
- </div>
- </div>
- </li>
- </ul>
- </div>
- </div>
- </div>
+ <CallContactsTab
+ :active="activeTab === 'contacts'"
+ :contacts-search="contactsSearch"
+ :contacts="contacts"
+ :format-destination-hash="formatDestinationHash"
+ @update:contacts-search="contactsSearch = $event"
+ @search-input="onContactsSearchInput"
+ @add="openAddContactModal"
+ @edit="openEditContactModal"
+ @delete="deleteContact"
+ @copy-hash="copyHash"
+ @call="onPhonebookCall"
+ />
<!-- Ringtone Tab -->
<div v-if="activeTab === 'ringtone' && config" class="flex-1 space-y-6 max-w-3xl mx-auto w-full">
@@ -2471,8 +1754,11 @@ import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import LxmfUserIcon from "../LxmfUserIcon.vue";
import Toggle from "../forms/Toggle.vue";
import ToastUtils from "../../js/ToastUtils";
-import RingtoneEditor from "./RingtoneEditor.vue";
-import AudioWaveformPlayer from "../messages/AudioWaveformPlayer.vue";
+import RingtoneEditor from "./audio/RingtoneEditor.vue";
+import CallPhonebookTab from "./tabs/CallPhonebookTab.vue";
+import CallContactsTab from "./tabs/CallContactsTab.vue";
+import CallVoicemailTab from "./tabs/CallVoicemailTab.vue";
+import CallTabBar from "./tabs/CallTabBar.vue";
// Keep this as a same-origin static asset URL so strict CSP can load it.
const telephonePcmCaptureWorkletUrl = "/assets/js/telephone-pcm-capture.worklet.js";
@@ -2484,7 +1770,10 @@ export default {
LxmfUserIcon,
Toggle,
RingtoneEditor,
- AudioWaveformPlayer,
+ CallPhonebookTab,
+ CallContactsTab,
+ CallVoicemailTab,
+ CallTabBar,
},
data() {
return {
@@ -2550,7 +1839,6 @@ export default {
lxst_address: "",
},
searchDebounceTimeout: null,
- isVoicemailSettingsExpanded: false,
selectedSuggestionIndex: -1,
isCallInputFocused: false,
recordings: [],
@@ -4077,6 +3365,11 @@ export default {
this.editingRingtoneForAudio = ringtone;
this.isRingtoneEditorOpen = true;
},
+ onPhonebookCall(destinationHash) {
+ this.destinationHash = destinationHash;
+ this.activeTab = "phone";
+ this.$nextTick(() => this.call(destinationHash));
+ },
onRingtoneSaved() {
this.getRingtones();
this.getRingtoneStatus();
@@ -4098,6 +3391,20 @@ export default {
this.getVoicemails();
}, 300);
},
+ onVoicemailPatchConfig(patch) {
+ if (!this.config || !patch) return;
+ Object.assign(this.config, patch);
+ },
+ onVoicemailUpdateConfig(patch) {
+ if (!this.config || !patch) return;
+ Object.assign(this.config, patch);
+ this.updateConfig(patch);
+ },
+ onVoicemailSaveAndGenerate() {
+ if (!this.config) return;
+ this.updateConfig({ voicemail_greeting: this.config.voicemail_greeting });
+ this.generateGreeting();
+ },
getContactByHash(hash) {
if (!hash) return null;
return this.contacts.find((c) => c.remote_identity_hash === hash);
diff --git a/meshchatx/src/frontend/components/call/RingtoneEditor.vue b/meshchatx/src/frontend/components/call/audio/RingtoneEditor.vue
similarity index 99%
rename from meshchatx/src/frontend/components/call/RingtoneEditor.vue
rename to meshchatx/src/frontend/components/call/audio/RingtoneEditor.vue
index 85407d7a..79ffc2de 100644
--- a/meshchatx/src/frontend/components/call/RingtoneEditor.vue
+++ b/meshchatx/src/frontend/components/call/audio/RingtoneEditor.vue
@@ -143,8 +143,8 @@
</template>
<script>
-import MaterialDesignIcon from "../MaterialDesignIcon.vue";
-import ToastUtils from "../../js/ToastUtils";
+import MaterialDesignIcon from "../../MaterialDesignIcon.vue";
+import ToastUtils from "../../../js/ToastUtils";
export default {
name: "RingtoneEditor",
diff --git a/meshchatx/src/frontend/components/call/tabs/CallContactsTab.vue b/meshchatx/src/frontend/components/call/tabs/CallContactsTab.vue
new file mode 100644
index 00000000..1d4a2c64
--- /dev/null
+++ b/meshchatx/src/frontend/components/call/tabs/CallContactsTab.vue
@@ -0,0 +1,177 @@
+<!-- SPDX-License-Identifier: 0BSD -->
+
+<template>
+ <div v-if="active" class="flex-1 flex flex-col max-w-3xl mx-auto w-full pt-2">
+ <div class="mb-4 flex gap-2">
+ <div class="relative flex-1">
+ <input
+ :value="contactsSearch"
+ type="text"
+ placeholder="Search contacts..."
+ class="block w-full rounded-lg border-0 py-2 pl-10 text-gray-900 dark:text-white shadow-xs ring-1 ring-inset ring-gray-300 dark:ring-zinc-800 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm dark:bg-zinc-900"
+ @input="onSearchInput"
+ />
+ <div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
+ <MaterialDesignIcon icon-name="magnify" class="size-5 text-gray-400" />
+ </div>
+ </div>
+ <button
+ type="button"
+ class="rounded-lg bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-blue-500 transition-colors flex items-center gap-2"
+ @click="$emit('add')"
+ >
+ <MaterialDesignIcon icon-name="plus" class="size-5" />
+ Add
+ </button>
+ </div>
+
+ <div v-if="contacts.length === 0" class="my-auto text-center">
+ <div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-full inline-block mb-4">
+ <MaterialDesignIcon icon-name="account-multiple" class="size-12 text-gray-400" />
+ </div>
+ <h3 class="text-lg font-medium text-gray-900 dark:text-white">No Contacts</h3>
+ <p class="text-gray-500 dark:text-zinc-400">Add contacts to quickly call them.</p>
+ </div>
+
+ <div v-else class="space-y-4">
+ <div class="border-b border-gray-200 dark:border-zinc-800 overflow-hidden">
+ <ul class="divide-y divide-gray-100 dark:divide-zinc-800">
+ <li
+ v-for="contact in contacts"
+ :key="contact.id"
+ class="px-4 py-3 hover:bg-gray-50 dark:hover:bg-zinc-800/50 transition-colors"
+ >
+ <div class="flex items-center space-x-3">
+ <div class="shrink-0">
+ <LxmfUserIcon
+ :custom-image="contact.custom_image"
+ :icon-name="contact.remote_icon ? contact.remote_icon.icon_name : ''"
+ :icon-foreground-colour="
+ contact.remote_icon ? contact.remote_icon.foreground_colour : ''
+ "
+ :icon-background-colour="
+ contact.remote_icon ? contact.remote_icon.background_colour : ''
+ "
+ class="size-10"
+ />
+ </div>
+ <div class="flex-1 min-w-0">
+ <div class="flex items-center justify-between">
+ <p class="text-sm font-bold text-gray-900 dark:text-white truncate">
+ {{ contact.name }}
+ </p>
+ <div class="flex items-center gap-2">
+ <span
+ v-if="contact.preferred_ringtone_id"
+ class="text-[9px] px-1.5 py-0.5 rounded-sm bg-amber-50 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400 border border-amber-100 dark:border-amber-800/50 flex items-center gap-1"
+ title="Custom Ringtone Set"
+ >
+ <MaterialDesignIcon icon-name="music" class="size-2.5" />
+ {{ contact.preferred_ringtone_id === -1 ? "Random" : "Custom" }}
+ </span>
+ <div class="flex items-center gap-1">
+ <button
+ type="button"
+ class="p-1.5 text-gray-400 hover:text-blue-500 transition-colors"
+ @click="$emit('edit', contact)"
+ >
+ <MaterialDesignIcon icon-name="pencil" class="size-4" />
+ </button>
+ <button
+ type="button"
+ class="p-1.5 text-gray-400 hover:text-red-500 transition-colors"
+ @click="$emit('delete', contact.id)"
+ >
+ <MaterialDesignIcon icon-name="delete" class="size-4" />
+ </button>
+ </div>
+ </div>
+ </div>
+ <div class="flex items-center justify-between mt-1">
+ <div class="flex flex-col min-w-0">
+ <span
+ class="text-[10px] text-gray-500 dark:text-zinc-500 font-mono truncate cursor-pointer hover:text-blue-500 transition-colors"
+ :title="contact.remote_identity_hash"
+ @click.stop="$emit('copy-hash', contact.remote_identity_hash)"
+ >
+ ID:
+ {{ formatDestinationHash(contact.remote_identity_hash) }}
+ </span>
+ <span
+ v-if="contact.lxmf_address"
+ class="text-[9px] text-gray-400 dark:text-zinc-500 font-mono truncate cursor-pointer hover:text-blue-500 transition-colors"
+ :title="contact.lxmf_address"
+ @click.stop="$emit('copy-hash', contact.lxmf_address)"
+ >
+ LXMF: {{ formatDestinationHash(contact.lxmf_address) }}
+ </span>
+ <span
+ v-if="contact.lxst_address"
+ class="text-[9px] text-gray-400 dark:text-zinc-500 font-mono truncate cursor-pointer hover:text-blue-500 transition-colors"
+ :title="contact.lxst_address"
+ @click.stop="$emit('copy-hash', contact.lxst_address)"
+ >
+ LXST: {{ formatDestinationHash(contact.lxst_address) }}
+ </span>
+ </div>
+ <button
+ type="button"
+ class="text-[10px] bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400 px-3 py-1 rounded-full font-bold uppercase tracking-wider hover:bg-blue-200 dark:hover:bg-blue-900/50 transition-colors shrink-0"
+ @click="
+ $emit(
+ 'call',
+ contact.remote_telephony_hash ||
+ contact.remote_destination_hash ||
+ contact.remote_identity_hash
+ )
+ "
+ >
+ Call
+ </button>
+ </div>
+ </div>
+ </div>
+ </li>
+ </ul>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+import MaterialDesignIcon from "../../MaterialDesignIcon.vue";
+import LxmfUserIcon from "../../LxmfUserIcon.vue";
+
+export default {
+ name: "CallContactsTab",
+ components: {
+ MaterialDesignIcon,
+ LxmfUserIcon,
+ },
+ props: {
+ active: {
+ type: Boolean,
+ default: false,
+ },
+ contactsSearch: {
+ type: String,
+ default: "",
+ },
+ contacts: {
+ type: Array,
+ default: () => [],
+ },
+ formatDestinationHash: {
+ type: Function,
+ required: true,
+ },
+ },
+ emits: ["update:contactsSearch", "search-input", "add", "edit", "delete", "copy-hash", "call"],
+ methods: {
+ onSearchInput(event) {
+ this.$emit("update:contactsSearch", event.target.value);
+ this.$emit("search-input");
+ },
+ },
+};
+</script>
diff --git a/meshchatx/src/frontend/components/call/tabs/CallPhonebookTab.vue b/meshchatx/src/frontend/components/call/tabs/CallPhonebookTab.vue
new file mode 100644
index 00000000..7a1b4d95
--- /dev/null
+++ b/meshchatx/src/frontend/components/call/tabs/CallPhonebookTab.vue
@@ -0,0 +1,156 @@
+<!-- SPDX-License-Identifier: 0BSD -->
+
+<template>
+ <div v-if="active" class="flex-1 flex flex-col max-w-3xl mx-auto w-full pt-2">
+ <div class="mb-4">
+ <div class="relative">
+ <input
+ :value="discoverySearch"
+ type="text"
+ :placeholder="`Search phonebook (${totalDiscoveryCount})...`"
+ class="block w-full rounded-lg border-0 py-2 pl-10 text-gray-900 dark:text-white shadow-xs ring-1 ring-inset ring-gray-300 dark:ring-zinc-800 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm dark:bg-zinc-900"
+ @input="onSearchInput"
+ />
+ <div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
+ <MaterialDesignIcon icon-name="magnify" class="size-5 text-gray-400" />
+ </div>
+ </div>
+ </div>
+
+ <div v-if="discoveryAnnounces.length === 0" class="my-auto text-center">
+ <div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-full inline-block mb-4">
+ <MaterialDesignIcon icon-name="satellite-uplink" class="size-12 text-gray-400" />
+ </div>
+ <h3 class="text-lg font-medium text-gray-900 dark:text-white">No Telephony Peers</h3>
+ <p class="text-gray-500 dark:text-zinc-400">Waiting for announces on the mesh.</p>
+ </div>
+
+ <div v-else class="space-y-4">
+ <div class="border-b border-gray-200 dark:border-zinc-800 overflow-hidden">
+ <ul class="divide-y divide-gray-100 dark:divide-zinc-800">
+ <li
+ v-for="announce in discoveryAnnounces"
+ :key="announce.destination_hash"
+ class="px-4 py-4 hover:bg-gray-50 dark:hover:bg-zinc-800/50 transition-colors"
+ >
+ <div class="flex items-center space-x-4">
+ <div class="shrink-0">
+ <LxmfUserIcon
+ :custom-image="announce.contact_image"
+ :icon-name="announce.lxmf_user_icon?.icon_name"
+ :icon-foreground-colour="announce.lxmf_user_icon?.foreground_colour"
+ :icon-background-colour="announce.lxmf_user_icon?.background_colour"
+ class="size-10"
+ />
+ </div>
+ <div class="flex-1 min-w-0">
+ <div class="flex items-center justify-between">
+ <div class="flex items-center min-w-0">
+ <p class="text-sm font-bold text-gray-900 dark:text-white truncate">
+ {{ announce.display_name || "Anonymous Peer" }}
+ </p>
+ <a
+ v-if="announce.lxmf_destination_hash"
+ :href="`/#/messages/${announce.lxmf_destination_hash}`"
+ class="ml-2 p-1 text-gray-400 hover:text-blue-500 transition-colors"
+ title="Message via LXMF"
+ @click.stop
+ >
+ <MaterialDesignIcon icon-name="message-text-outline" class="size-4" />
+ </a>
+ </div>
+ <span class="text-[10px] text-gray-500 dark:text-zinc-500 font-mono ml-2 shrink-0">
+ {{ formatTimeAgo(announce.updated_at) }}
+ </span>
+ </div>
+ <div class="flex items-center justify-between mt-1">
+ <div class="flex items-center space-x-2 min-w-0">
+ <span
+ class="text-[10px] text-gray-500 dark:text-zinc-500 font-mono truncate cursor-pointer hover:text-blue-500 transition-colors"
+ :title="announce.destination_hash"
+ @click.stop="$emit('copy-hash', announce.destination_hash)"
+ >
+ {{ formatDestinationHash(announce.destination_hash) }}
+ </span>
+ <span
+ v-if="announce.hops != null"
+ class="text-[10px] text-gray-400 dark:text-zinc-600"
+ >
+ • {{ announce.hops }} hops
+ </span>
+ </div>
+ <button
+ type="button"
+ class="text-[10px] bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400 px-3 py-1 rounded-full font-bold uppercase tracking-wider hover:bg-blue-200 dark:hover:bg-blue-900/50 transition-colors shrink-0"
+ @click="$emit('call', announce.destination_hash)"
+ >
+ Call
+ </button>
+ </div>
+ </div>
+ </div>
+ </li>
+ </ul>
+ <div v-if="hasMoreDiscovery" class="p-3 border-t border-gray-100 dark:border-zinc-800 text-center">
+ <button
+ type="button"
+ class="text-xs text-blue-500 hover:text-blue-600 font-bold uppercase tracking-widest"
+ @click="$emit('load-more')"
+ >
+ {{ $t("call.load_more") }}
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+import MaterialDesignIcon from "../../MaterialDesignIcon.vue";
+import LxmfUserIcon from "../../LxmfUserIcon.vue";
+
+export default {
+ name: "CallPhonebookTab",
+ components: {
+ MaterialDesignIcon,
+ LxmfUserIcon,
+ },
+ props: {
+ active: {
+ type: Boolean,
+ default: false,
+ },
+ discoverySearch: {
+ type: String,
+ default: "",
+ },
+ totalDiscoveryCount: {
+ type: Number,
+ default: 0,
+ },
+ discoveryAnnounces: {
+ type: Array,
+ default: () => [],
+ },
+ hasMoreDiscovery: {
+ type: Boolean,
+ default: false,
+ },
+ formatTimeAgo: {
+ type: Function,
+ required: true,
+ },
+ formatDestinationHash: {
+ type: Function,
+ required: true,
+ },
+ },
+ emits: ["update:discoverySearch", "search-input", "copy-hash", "load-more", "call"],
+ methods: {
+ onSearchInput(event) {
+ this.$emit("update:discoverySearch", event.target.value);
+ this.$emit("search-input");
+ },
+ },
+};
+</script>
diff --git a/meshchatx/src/frontend/components/call/tabs/CallTabBar.vue b/meshchatx/src/frontend/components/call/tabs/CallTabBar.vue
new file mode 100644
index 00000000..e5b6b83d
--- /dev/null
+++ b/meshchatx/src/frontend/components/call/tabs/CallTabBar.vue
@@ -0,0 +1,70 @@
+<!-- SPDX-License-Identifier: 0BSD -->
+
+<template>
+ <div class="flex flex-wrap justify-center border-b border-gray-200 dark:border-zinc-800 shrink-0">
+ <button
+ :class="tabClass('phone')"
+ class="py-2 px-4 border-b-2 font-medium text-sm transition-all"
+ @click="$emit('update:activeTab', 'phone')"
+ >
+ Phone
+ </button>
+ <button
+ :class="tabClass('phonebook')"
+ class="py-2 px-4 border-b-2 font-medium text-sm transition-all"
+ @click="$emit('update:activeTab', 'phonebook')"
+ >
+ Phonebook
+ </button>
+ <button
+ :class="tabClass('voicemail')"
+ class="py-2 px-4 border-b-2 font-medium text-sm flex items-center gap-2 transition-all"
+ @click="$emit('update:activeTab', 'voicemail')"
+ >
+ Voicemail
+ <span
+ v-if="unreadVoicemailsCount > 0"
+ class="bg-red-500 text-white text-[10px] px-1.5 py-0.5 rounded-full animate-pulse"
+ >{{ unreadVoicemailsCount }}</span
+ >
+ </button>
+ <button
+ :class="tabClass('contacts')"
+ class="py-2 px-4 border-b-2 font-medium text-sm transition-all"
+ @click="$emit('update:activeTab', 'contacts')"
+ >
+ Contacts
+ </button>
+ <button
+ :class="tabClass('ringtone')"
+ class="py-2 px-4 border-b-2 font-medium text-sm transition-all"
+ @click="$emit('update:activeTab', 'ringtone')"
+ >
+ {{ $t("call.ringtone") }}
+ </button>
+ </div>
+</template>
+
+<script>
+export default {
+ name: "CallTabBar",
+ props: {
+ activeTab: {
+ type: String,
+ required: true,
+ },
+ unreadVoicemailsCount: {
+ type: Number,
+ default: 0,
+ },
+ },
+ emits: ["update:activeTab"],
+ methods: {
+ tabClass(tab) {
+ return this.activeTab === tab
+ ? "border-blue-500 text-blue-600 dark:text-blue-400"
+ : "border-transparent text-gray-500 hover:text-gray-700 dark:text-zinc-400 dark:hover:text-zinc-200 hover:border-gray-300";
+ },
+ },
+};
+</script>
diff --git a/meshchatx/src/frontend/components/call/tabs/CallVoicemailTab.vue b/meshchatx/src/frontend/components/call/tabs/CallVoicemailTab.vue
new file mode 100644
index 00000000..2aba23db
--- /dev/null
+++ b/meshchatx/src/frontend/components/call/tabs/CallVoicemailTab.vue
@@ -0,0 +1,489 @@
+<!-- SPDX-License-Identifier: 0BSD -->
+
+<template>
+ <div v-if="active" class="flex-1 flex flex-col max-w-3xl mx-auto w-full pt-2">
+ <div class="mb-4">
+ <div class="relative">
+ <input
+ :value="voicemailSearch"
+ type="text"
+ placeholder="Search voicemails..."
+ class="block w-full rounded-lg border-0 py-2 pl-10 text-gray-900 dark:text-white shadow-xs ring-1 ring-inset ring-gray-300 dark:ring-zinc-800 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm dark:bg-zinc-900"
+ @input="onSearchInput"
+ />
+ <div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
+ <MaterialDesignIcon icon-name="magnify" class="size-5 text-gray-400" />
+ </div>
+ </div>
+ </div>
+
+ <!-- Voicemail Settings Card -->
+ <div v-if="config" class="mb-4 border-b border-gray-200 dark:border-zinc-800 overflow-hidden">
+ <button
+ type="button"
+ class="w-full px-4 py-3 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-zinc-800/50 transition-colors"
+ @click="isVoicemailSettingsExpanded = !isVoicemailSettingsExpanded"
+ >
+ <div class="flex items-center gap-2">
+ <MaterialDesignIcon icon-name="cog" class="size-5 text-blue-500" />
+ <h3 class="text-sm font-bold text-gray-900 dark:text-white uppercase tracking-wider">
+ Voicemail Settings
+ </h3>
+ </div>
+ <MaterialDesignIcon
+ :icon-name="isVoicemailSettingsExpanded ? 'chevron-up' : 'chevron-down'"
+ class="size-5 text-gray-400"
+ />
+ </button>
+
+ <div v-if="isVoicemailSettingsExpanded" class="px-4 pb-6 space-y-6">
+ <!-- Status Banner -->
+ <div
+ v-if="!voicemailStatus.has_espeak"
+ class="p-4 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg flex gap-3 items-start"
+ >
+ <MaterialDesignIcon icon-name="alert" class="size-5 text-amber-600 dark:text-amber-400 shrink-0" />
+ <div class="text-xs text-amber-800 dark:text-amber-200">
+ <p class="font-bold mb-1">Dependencies Missing</p>
+ <p>Voicemail requires `espeak-ng` to generate greetings. Please install it on your system.</p>
+ </div>
+ </div>
+
+ <!-- Enabled Toggle -->
+ <div class="flex items-center justify-between">
+ <div>
+ <div class="text-sm font-semibold text-gray-900 dark:text-white">Enable Voicemail</div>
+ <div class="text-xs text-gray-500 dark:text-zinc-400">
+ Accept calls automatically and record messages
+ </div>
+ </div>
+ <button
+ :disabled="!voicemailStatus.has_espeak"
+ class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-hidden disabled:opacity-50 disabled:cursor-not-allowed"
+ :class="config.voicemail_enabled ? 'bg-blue-600' : 'bg-gray-200 dark:bg-zinc-700'"
+ @click="$emit('update-config', { voicemail_enabled: !config.voicemail_enabled })"
+ >
+ <span
+ class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow-sm ring-0 transition duration-200 ease-in-out"
+ :class="config.voicemail_enabled ? 'translate-x-5' : 'translate-x-0'"
+ ></span>
+ </button>
+ </div>
+
+ <!-- Greeting Text -->
+ <div class="space-y-2">
+ <label class="text-xs font-bold text-gray-500 dark:text-zinc-400 uppercase tracking-tighter"
+ >Greeting Message</label
+ >
+ <textarea
+ :value="config.voicemail_greeting"
+ rows="3"
+ class="block w-full rounded-lg border-0 py-2 text-gray-900 dark:text-white shadow-xs ring-1 ring-inset ring-gray-300 dark:ring-zinc-800 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6 dark:bg-zinc-900"
+ placeholder="Enter greeting text..."
+ @input="$emit('patch-config', { voicemail_greeting: $event.target.value })"
+ ></textarea>
+
+ <!-- TTS Settings -->
+ <div class="grid grid-cols-2 gap-3 mt-2">
+ <div class="space-y-1">
+ <label
+ class="text-[10px] font-bold text-gray-500 dark:text-zinc-500 uppercase tracking-tighter"
+ >{{ $t("call.tts_speed") }}</label
+ >
+ <input
+ :value="config.voicemail_tts_speed"
+ type="number"
+ min="80"
+ max="450"
+ class="block w-full rounded-lg border-0 py-1 text-gray-900 dark:text-white shadow-xs ring-1 ring-inset ring-gray-300 dark:ring-zinc-800 focus:ring-2 focus:ring-inset focus:ring-blue-600 text-xs dark:bg-zinc-900"
+ @change="
+ $emit('update-config', {
+ voicemail_tts_speed: Number($event.target.value),
+ })
+ "
+ />
+ </div>
+ <div class="space-y-1">
+ <label
+ class="text-[10px] font-bold text-gray-500 dark:text-zinc-500 uppercase tracking-tighter"
+ >{{ $t("call.tts_pitch") }}</label
+ >
+ <input
+ :value="config.voicemail_tts_pitch"
+ type="number"
+ min="0"
+ max="99"
+ class="block w-full rounded-lg border-0 py-1 text-gray-900 dark:text-white shadow-xs ring-1 ring-inset ring-gray-300 dark:ring-zinc-800 focus:ring-2 focus:ring-inset focus:ring-blue-600 text-xs dark:bg-zinc-900"
+ @change="
+ $emit('update-config', {
+ voicemail_tts_pitch: Number($event.target.value),
+ })
+ "
+ />
+ </div>
+ <div class="space-y-1">
+ <label
+ class="text-[10px] font-bold text-gray-500 dark:text-zinc-500 uppercase tracking-tighter"
+ >{{ $t("call.tts_word_gap") }}</label
+ >
+ <input
+ :value="config.voicemail_tts_word_gap"
+ type="number"
+ min="0"
+ max="100"
+ class="block w-full rounded-lg border-0 py-1 text-gray-900 dark:text-white shadow-xs ring-1 ring-inset ring-gray-300 dark:ring-zinc-800 focus:ring-2 focus:ring-inset focus:ring-blue-600 text-xs dark:bg-zinc-900"
+ @change="
+ $emit('update-config', {
+ voicemail_tts_word_gap: Number($event.target.value),
+ })
+ "
+ />
+ </div>
+ <div class="space-y-1">
+ <label
+ class="text-[10px] font-bold text-gray-500 dark:text-zinc-500 uppercase tracking-tighter"
+ >{{ $t("call.tts_voice") }}</label
+ >
+ <input
+ :value="config.voicemail_tts_voice"
+ type="text"
+ class="block w-full rounded-lg border-0 py-1 text-gray-900 dark:text-white shadow-xs ring-1 ring-inset ring-gray-300 dark:ring-zinc-800 focus:ring-2 focus:ring-inset focus:ring-blue-600 text-xs dark:bg-zinc-900"
+ @change="$emit('update-config', { voicemail_tts_voice: $event.target.value })"
+ />
+ </div>
+ </div>
+
+ <div class="flex justify-between items-center">
+ <p class="text-[10px] text-gray-500 dark:text-zinc-500">
+ This text will be converted to speech using eSpeak NG.
+ </p>
+ <div class="flex gap-2">
+ <button
+ :disabled="!voicemailStatus.has_espeak || isGeneratingGreeting"
+ class="text-[10px] bg-gray-100 dark:bg-zinc-800 text-gray-700 dark:text-zinc-300 px-3 py-1 rounded-full font-bold hover:bg-gray-200 dark:hover:bg-zinc-700 transition-colors disabled:opacity-50"
+ @click="$emit('save-and-generate')"
+ >
+ {{ isGeneratingGreeting ? "Generating..." : "Save & Generate" }}
+ </button>
+ </div>
+ </div>
+ </div>
+
+ <!-- Custom Greeting Upload -->
+ <div class="space-y-2">
+ <label class="text-xs font-bold text-gray-500 dark:text-zinc-400 uppercase tracking-tighter"
+ >Custom Audio Greeting</label
+ >
+ <div class="flex items-center gap-3 flex-wrap">
+ <input
+ ref="greetingUpload"
+ type="file"
+ accept="audio/*"
+ class="hidden"
+ @change="$emit('upload-greeting', $event)"
+ />
+ <button
+ :disabled="isUploadingGreeting || voicemailStatus.is_greeting_recording"
+ class="text-xs bg-gray-100 dark:bg-zinc-800 text-gray-700 dark:text-zinc-300 px-4 py-2 rounded-lg font-bold hover:bg-gray-200 dark:hover:bg-zinc-700 transition-colors disabled:opacity-50 flex items-center gap-2"
+ @click="$refs.greetingUpload.click()"
+ >
+ <MaterialDesignIcon icon-name="upload" class="size-4" />
+ {{ isUploadingGreeting ? "Uploading..." : "Upload Audio File" }}
+ </button>
+ <button
+ class="text-xs px-4 py-2 rounded-lg font-bold transition-colors flex items-center gap-2"
+ :class="
+ voicemailStatus.is_greeting_recording
+ ? 'bg-red-500 text-white animate-pulse'
+ : 'bg-gray-100 dark:bg-zinc-800 text-gray-700 dark:text-zinc-300 hover:bg-gray-200 dark:hover:bg-zinc-700'
+ "
+ @click="
+ voicemailStatus.is_greeting_recording
+ ? $emit('stop-recording-greeting')
+ : $emit('start-recording-greeting')
+ "
+ >
+ <MaterialDesignIcon
+ :icon-name="voicemailStatus.is_greeting_recording ? 'stop' : 'microphone'"
+ class="size-4"
+ />
+ {{ voicemailStatus.is_greeting_recording ? "Stop Recording" : "Record from Mic" }}
+ </button>
+
+ <div v-if="voicemailStatus.has_greeting" class="flex items-center gap-2">
+ <button
+ class="text-xs bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 px-4 py-2 rounded-lg font-bold hover:bg-red-200 dark:hover:bg-red-900/50 transition-colors flex items-center gap-2"
+ @click="$emit('delete-greeting')"
+ >
+ <MaterialDesignIcon icon-name="delete" class="size-4" />
+ Remove Greeting
+ </button>
+ <button
+ class="text-xs bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 px-4 py-2 rounded-lg font-bold hover:bg-blue-200 dark:hover:bg-blue-900/50 transition-colors flex items-center gap-2"
+ @click="$emit('play-greeting')"
+ >
+ <MaterialDesignIcon :icon-name="isPlayingGreeting ? 'stop' : 'play'" class="size-4" />
+ {{ isPlayingGreeting ? "Stop Preview" : "Preview" }}
+ </button>
+ </div>
+ <div v-else class="text-[10px] text-gray-500 dark:text-zinc-500 italic">
+ No custom greeting uploaded (default text will be used)
+ </div>
+ </div>
+ <p class="text-[10px] text-gray-500 dark:text-zinc-500">
+ Supports MP3, OGG, WAV, M4A, FLAC. Will be converted to Opus.
+ </p>
+ </div>
+
+ <!-- Delays -->
+ <div class="grid grid-cols-2 gap-4">
+ <div class="space-y-2">
+ <label class="text-xs font-bold text-gray-500 dark:text-zinc-400 uppercase tracking-tighter"
+ >Answer Delay (s)</label
+ >
+ <input
+ :value="config.voicemail_auto_answer_delay_seconds"
+ type="number"
+ min="1"
+ max="120"
+ class="block w-full rounded-lg border-0 py-1.5 text-gray-900 dark:text-white shadow-xs ring-1 ring-inset ring-gray-300 dark:ring-zinc-800 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm dark:bg-zinc-900"
+ @change="
+ $emit('update-config', {
+ voicemail_auto_answer_delay_seconds: Number($event.target.value),
+ })
+ "
+ />
+ </div>
+ <div class="space-y-2">
+ <label class="text-xs font-bold text-gray-500 dark:text-zinc-400 uppercase tracking-tighter"
+ >Max Recording (s)</label
+ >
+ <input
+ :value="config.voicemail_max_recording_seconds"
+ type="number"
+ min="5"
+ max="600"
+ class="block w-full rounded-lg border-0 py-1.5 text-gray-900 dark:text-white shadow-xs ring-1 ring-inset ring-gray-300 dark:ring-zinc-800 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm dark:bg-zinc-900"
+ @change="
+ $emit('update-config', {
+ voicemail_max_recording_seconds: Number($event.target.value),
+ })
+ "
+ />
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div v-if="voicemails.length === 0" class="my-auto text-center">
+ <div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-full inline-block mb-4">
+ <MaterialDesignIcon icon-name="voicemail" class="size-12 text-gray-400" />
+ </div>
+ <h3 class="text-lg font-medium text-gray-900 dark:text-white">No Voicemails</h3>
+ <p class="text-gray-500 dark:text-zinc-400">When people leave you messages, they'll show up here.</p>
+ </div>
+
+ <div v-else class="space-y-4">
+ <div class="border-b border-gray-200 dark:border-zinc-800 overflow-hidden">
+ <div class="px-4 py-3 border-b border-gray-200 dark:border-zinc-800 flex justify-between items-center">
+ <h3 class="text-sm font-bold text-gray-900 dark:text-white uppercase tracking-wider">
+ Voicemail Inbox
+ </h3>
+ <span
+ class="text-[10px] bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400 px-2 py-0.5 rounded-full font-bold uppercase"
+ >
+ {{ voicemails.length }} Messages
+ </span>
+ </div>
+ <ul class="divide-y divide-gray-100 dark:divide-zinc-800">
+ <li
+ v-for="voicemail in voicemails"
+ :key="voicemail.id"
+ class="px-4 py-4 hover:bg-gray-50 dark:hover:bg-zinc-800/50 transition-colors"
+ :class="{ 'bg-blue-50/50 dark:bg-blue-900/10': !voicemail.is_read }"
+ >
+ <div class="flex items-start space-x-4">
+ <!-- Icon / Play/Pause Button -->
+ <div class="relative shrink-0">
+ <LxmfUserIcon
+ :custom-image="getContactByHash(voicemail.remote_identity_hash)?.custom_image"
+ :icon-name="voicemail.remote_icon ? voicemail.remote_icon.icon_name : ''"
+ :icon-foreground-colour="
+ voicemail.remote_icon ? voicemail.remote_icon.foreground_colour : ''
+ "
+ :icon-background-colour="
+ voicemail.remote_icon ? voicemail.remote_icon.background_colour : ''
+ "
+ class="size-10"
+ />
+ </div>
+
+ <div class="flex-1 min-w-0">
+ <div class="flex items-center justify-between mb-1">
+ <div class="flex items-center min-w-0 mr-2">
+ <p class="text-sm font-bold text-gray-900 dark:text-white truncate">
+ {{ voicemail.remote_identity_name || $t("call.unknown") }}
+ </p>
+ <span
+ v-if="!voicemail.is_read"
+ class="ml-2 shrink-0 size-2 inline-block rounded-full bg-blue-500"
+ ></span>
+ </div>
+ <span class="text-[10px] text-gray-500 dark:text-zinc-500 font-mono shrink-0">
+ {{ formatDateTime(voicemail.timestamp * 1000) }}
+ </span>
+ </div>
+
+ <div class="flex items-center text-xs text-gray-500 dark:text-zinc-400 space-x-3 mb-3">
+ <span class="flex items-center gap-1">
+ <MaterialDesignIcon icon-name="clock-outline" class="size-3" />
+ {{ formatDuration(voicemail.duration_seconds) }}
+ </span>
+ <span
+ class="opacity-60 font-mono text-[10px] truncate cursor-pointer hover:text-blue-500 transition-colors"
+ :title="voicemail.remote_identity_hash"
+ @click.stop="$emit('copy-hash', voicemail.remote_identity_hash)"
+ >{{ formatDestinationHash(voicemail.remote_identity_hash) }}</span
+ >
+ </div>
+
+ <div class="mb-4">
+ <AudioWaveformPlayer
+ :src="`/api/v1/telephone/voicemails/${voicemail.id}/audio`"
+ @play="$emit('mark-read', voicemail)"
+ />
+ </div>
+
+ <div class="flex items-center gap-4">
+ <button
+ type="button"
+ class="text-[10px] flex items-center gap-1 text-gray-500 hover:text-blue-500 font-bold uppercase tracking-wider transition-colors"
+ @click="
+ $emit(
+ 'call-back',
+ voicemail.remote_telephony_hash ||
+ voicemail.remote_destination_hash ||
+ voicemail.remote_identity_hash
+ )
+ "
+ >
+ <MaterialDesignIcon icon-name="phone" class="size-3" />
+ Call Back
+ </button>
+ <a
+ :href="`/api/v1/telephone/voicemails/${voicemail.id}/audio`"
+ :download="`voicemail_${voicemail.id}.opus`"
+ class="text-[10px] flex items-center gap-1 text-gray-500 hover:text-blue-500 font-bold uppercase tracking-wider transition-colors"
+ >
+ <MaterialDesignIcon icon-name="download" class="size-3" />
+ Download
+ </a>
+ <button
+ type="button"
+ class="text-[10px] flex items-center gap-1 text-red-500 hover:text-red-600 font-bold uppercase tracking-wider transition-colors"
+ @click="$emit('delete', voicemail.id)"
+ >
+ <MaterialDesignIcon icon-name="delete" class="size-3" />
+ Delete
+ </button>
+ </div>
+ </div>
+ </div>
+ </li>
+ </ul>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+import MaterialDesignIcon from "../../MaterialDesignIcon.vue";
+import LxmfUserIcon from "../../LxmfUserIcon.vue";
+import AudioWaveformPlayer from "../../messages/AudioWaveformPlayer.vue";
+
+export default {
+ name: "CallVoicemailTab",
+ components: {
+ MaterialDesignIcon,
+ LxmfUserIcon,
+ AudioWaveformPlayer,
+ },
+ props: {
+ active: {
+ type: Boolean,
+ default: false,
+ },
+ voicemailSearch: {
+ type: String,
+ default: "",
+ },
+ config: {
+ type: Object,
+ default: null,
+ },
+ voicemailStatus: {
+ type: Object,
+ default: () => ({}),
+ },
+ voicemails: {
+ type: Array,
+ default: () => [],
+ },
+ isGeneratingGreeting: {
+ type: Boolean,
+ default: false,
+ },
+ isUploadingGreeting: {
+ type: Boolean,
+ default: false,
+ },
+ isPlayingGreeting: {
+ type: Boolean,
+ default: false,
+ },
+ getContactByHash: {
+ type: Function,
+ required: true,
+ },
+ formatDateTime: {
+ type: Function,
+ required: true,
+ },
+ formatDuration: {
+ type: Function,
+ required: true,
+ },
+ formatDestinationHash: {
+ type: Function,
+ required: true,
+ },
+ },
+ emits: [
+ "update:voicemailSearch",
+ "search-input",
+ "update-config",
+ "patch-config",
+ "save-and-generate",
+ "upload-greeting",
+ "start-recording-greeting",
+ "stop-recording-greeting",
+ "delete-greeting",
+ "play-greeting",
+ "copy-hash",
+ "mark-read",
+ "call-back",
+ "delete",
+ ],
+ data() {
+ return {
+ isVoicemailSettingsExpanded: false,
+ };
+ },
+ methods: {
+ onSearchInput(event) {
+ this.$emit("update:voicemailSearch", event.target.value);
+ this.$emit("search-input");
+ },
+ },
+};
+</script>
diff --git a/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue b/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue
index ddf61380..31f5834a 100644
--- a/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue
+++ b/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue
@@ -1519,139 +1519,7 @@
</ExpandingSection>
<!-- Interface Discovery Settings -->
- <ExpandingSection class="glass-card p-0! overflow-hidden">
- <template #title
- ><span class="text-sm font-bold">Interface Discovery</span></template
- >
- <template #content>
- <div class="p-6 space-y-6">
- <div class="flex items-center justify-between">
- <div class="max-w-md">
- <FormLabel class="glass-label mb-0!"
- >Publish Discovery Announce</FormLabel
- >
- <p class="text-xs text-gray-400">
- Makes your node visible to others on the network.
- </p>
- </div>
- <Toggle v-model="discovery.discoverable" />
- </div>
- <div
- v-if="discovery.discoverable"
- class="space-y-4 pt-4 border-t border-gray-100 dark:border-zinc-800 animate-in fade-in slide-in-from-top-2"
- >
- <div>
- <FormLabel class="glass-label">Discovery Name</FormLabel>
- <input
- v-model="discovery.discovery_name"
- type="text"
- placeholder="Human-friendly name"
- class="input-field"
- />
- </div>
- <div class="grid grid-cols-2 gap-4">
- <div>
- <FormLabel class="glass-label">Announce Interval (m)</FormLabel>
- <input
- v-model.number="discovery.announce_interval"
- type="number"
- class="input-field"
- />
- </div>
- <div>
- <FormLabel class="glass-label">Reachable On</FormLabel>
- <input
- v-model="discovery.reachable_on"
- type="text"
- placeholder="IP or Hostname"
- class="input-field"
- />
- </div>
- </div>
- <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
- <div>
- <FormLabel class="glass-label">Latitude (optional)</FormLabel>
- <input
- v-model="discovery.latitude"
- type="text"
- inputmode="decimal"
- autocomplete="off"
- aria-required="false"
- placeholder="Leave blank if unknown"
- class="input-field"
- />
- </div>
- <div>
- <FormLabel class="glass-label">Longitude (optional)</FormLabel>
- <input
- v-model="discovery.longitude"
- type="text"
- inputmode="decimal"
- autocomplete="off"
- aria-required="false"
- placeholder="Leave blank if unknown"
- class="input-field"
- />
- </div>
- <div>
- <FormLabel class="glass-label"
- >Height in metres (optional)</FormLabel
- >
- <input
- v-model="discovery.height"
- type="text"
- inputmode="decimal"
- autocomplete="off"
- aria-required="false"
- placeholder="Leave blank if unknown"
- class="input-field"
- />
- </div>
- </div>
- <div>
- <FormLabel class="glass-label">{{
- $t("interfaces.location_cmd_label")
- }}</FormLabel>
- <input
- v-model="discovery.location_cmd"
- type="text"
- :placeholder="$t('interfaces.location_cmd_placeholder')"
- class="input-field font-mono text-xs"
- autocomplete="off"
- />
- <p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
- {{ $t("interfaces.location_cmd_hint") }}
- </p>
- </div>
- <div class="grid grid-cols-2 gap-4">
- <div>
- <FormLabel class="glass-label">Discovery stamp value</FormLabel>
- <input
- v-model.number="discovery.discovery_stamp_value"
- type="number"
- min="1"
- class="input-field"
- />
- </div>
- </div>
- <div class="flex flex-wrap items-center justify-between gap-4">
- <div class="flex items-center justify-between gap-4 max-w-md">
- <FormLabel class="glass-label mb-0!"
- >Encrypt discovery</FormLabel
- >
- <Toggle v-model="discovery.discovery_encrypt" />
- </div>
- <div class="flex items-center justify-between gap-4 max-w-md">
- <FormLabel class="glass-label mb-0!"
- >Publish IFAC in announce</FormLabel
- >
- <Toggle v-model="discovery.publish_ifac" />
- </div>
- </div>
- </div>
- </div>
- </template>
- </ExpandingSection>
+ <AddInterfaceDiscoveryPanel :discovery="discovery" @patch="applyDiscoveryPatch" />
<!-- Global Discovery Settings -->
<ExpandingSection class="glass-card p-0! overflow-hidden">
@@ -2043,6 +1911,7 @@ import DialogUtils from "../../js/DialogUtils";
import ToastUtils from "../../js/ToastUtils";
import { numOrNull, parseRNodeFrequencyHz } from "../../js/interfaceDiscoveryUtils";
import ExpandingSection from "./ExpandingSection.vue";
+import AddInterfaceDiscoveryPanel from "./internal/AddInterfaceDiscoveryPanel.vue";
import FormLabel from "../forms/FormLabel.vue";
import Toggle from "../forms/Toggle.vue";
import GlobalState from "../../js/GlobalState";
@@ -2056,6 +1925,7 @@ export default {
MaterialDesignIcon,
FormLabel,
ExpandingSection,
+ AddInterfaceDiscoveryPanel,
Toggle,
BundledDocsHint,
},
@@ -2355,6 +2225,12 @@ export default {
}
},
methods: {
+ applyDiscoveryPatch(patch) {
+ this.discovery = {
+ ...this.discovery,
+ ...patch,
+ };
+ },
async loadInstalledInterfaceModules() {
try {
const response = await window.api.get("/api/v1/reticulum/interface-modules");
diff --git a/meshchatx/src/frontend/components/interfaces/internal/AddInterfaceDiscoveryPanel.vue b/meshchatx/src/frontend/components/interfaces/internal/AddInterfaceDiscoveryPanel.vue
new file mode 100644
index 00000000..fab465d2
--- /dev/null
+++ b/meshchatx/src/frontend/components/interfaces/internal/AddInterfaceDiscoveryPanel.vue
@@ -0,0 +1,167 @@
+<!-- SPDX-License-Identifier: 0BSD -->
+
+<template>
+ <ExpandingSection class="glass-card p-0! overflow-hidden">
+ <template #title><span class="text-sm font-bold">Interface Discovery</span></template>
+ <template #content>
+ <div class="p-6 space-y-6">
+ <div class="flex items-center justify-between">
+ <div class="max-w-md">
+ <FormLabel class="glass-label mb-0!">Publish Discovery Announce</FormLabel>
+ <p class="text-xs text-gray-400">Makes your node visible to others on the network.</p>
+ </div>
+ <Toggle
+ :model-value="discovery.discoverable"
+ @update:model-value="patchField('discoverable', $event)"
+ />
+ </div>
+ <div
+ v-if="discovery.discoverable"
+ class="space-y-4 pt-4 border-t border-gray-100 dark:border-zinc-800 animate-in fade-in slide-in-from-top-2"
+ >
+ <div>
+ <FormLabel class="glass-label">Discovery Name</FormLabel>
+ <input
+ :value="discovery.discovery_name"
+ type="text"
+ placeholder="Human-friendly name"
+ class="input-field"
+ @input="patchField('discovery_name', $event.target.value)"
+ />
+ </div>
+ <div class="grid grid-cols-2 gap-4">
+ <div>
+ <FormLabel class="glass-label">Announce Interval (m)</FormLabel>
+ <input
+ :value="discovery.announce_interval"
+ type="number"
+ class="input-field"
+ @input="patchField('announce_interval', Number($event.target.value))"
+ />
+ </div>
+ <div>
+ <FormLabel class="glass-label">Reachable On</FormLabel>
+ <input
+ :value="discovery.reachable_on"
+ type="text"
+ placeholder="IP or Hostname"
+ class="input-field"
+ @input="patchField('reachable_on', $event.target.value)"
+ />
+ </div>
+ </div>
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
+ <div>
+ <FormLabel class="glass-label">Latitude (optional)</FormLabel>
+ <input
+ :value="discovery.latitude"
+ type="text"
+ inputmode="decimal"
+ autocomplete="off"
+ aria-required="false"
+ placeholder="Leave blank if unknown"
+ class="input-field"
+ @input="patchField('latitude', $event.target.value)"
+ />
+ </div>
+ <div>
+ <FormLabel class="glass-label">Longitude (optional)</FormLabel>
+ <input
+ :value="discovery.longitude"
+ type="text"
+ inputmode="decimal"
+ autocomplete="off"
+ aria-required="false"
+ placeholder="Leave blank if unknown"
+ class="input-field"
+ @input="patchField('longitude', $event.target.value)"
+ />
+ </div>
+ <div>
+ <FormLabel class="glass-label">Height in metres (optional)</FormLabel>
+ <input
+ :value="discovery.height"
+ type="text"
+ inputmode="decimal"
+ autocomplete="off"
+ aria-required="false"
+ placeholder="Leave blank if unknown"
+ class="input-field"
+ @input="patchField('height', $event.target.value)"
+ />
+ </div>
+ </div>
+ <div>
+ <FormLabel class="glass-label">{{ $t("interfaces.location_cmd_label") }}</FormLabel>
+ <input
+ :value="discovery.location_cmd"
+ type="text"
+ :placeholder="$t('interfaces.location_cmd_placeholder')"
+ class="input-field font-mono text-xs"
+ autocomplete="off"
+ @input="patchField('location_cmd', $event.target.value)"
+ />
+ <p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
+ {{ $t("interfaces.location_cmd_hint") }}
+ </p>
+ </div>
+ <div class="grid grid-cols-2 gap-4">
+ <div>
+ <FormLabel class="glass-label">Discovery stamp value</FormLabel>
+ <input
+ :value="discovery.discovery_stamp_value"
+ type="number"
+ min="1"
+ class="input-field"
+ @input="patchField('discovery_stamp_value', Number($event.target.value))"
+ />
+ </div>
+ </div>
+ <div class="flex flex-wrap items-center justify-between gap-4">
+ <div class="flex items-center justify-between gap-4 max-w-md">
+ <FormLabel class="glass-label mb-0!">Encrypt discovery</FormLabel>
+ <Toggle
+ :model-value="discovery.discovery_encrypt"
+ @update:model-value="patchField('discovery_encrypt', $event)"
+ />
+ </div>
+ <div class="flex items-center justify-between gap-4 max-w-md">
+ <FormLabel class="glass-label mb-0!">Publish IFAC in announce</FormLabel>
+ <Toggle
+ :model-value="discovery.publish_ifac"
+ @update:model-value="patchField('publish_ifac', $event)"
+ />
+ </div>
+ </div>
+ </div>
+ </div>
+ </template>
+ </ExpandingSection>
+</template>
+
+<script>
+import ExpandingSection from "../ExpandingSection.vue";
+import FormLabel from "../../forms/FormLabel.vue";
+import Toggle from "../../forms/Toggle.vue";
+
+export default {
+ name: "AddInterfaceDiscoveryPanel",
+ components: {
+ ExpandingSection,
+ FormLabel,
+ Toggle,
+ },
+ props: {
+ discovery: {
+ type: Object,
+ required: true,
+ },
+ },
+ emits: ["patch"],
+ methods: {
+ patchField(key, value) {
+ this.$emit("patch", { [key]: value });
+ },
+ },
+};
+</script>
diff --git a/meshchatx/src/frontend/components/layout/AppIdentitySwitchOverlay.vue b/meshchatx/src/frontend/components/layout/AppIdentitySwitchOverlay.vue
new file mode 100644
index 00000000..d9ffce29
--- /dev/null
+++ b/meshchatx/src/frontend/components/layout/AppIdentitySwitchOverlay.vue
@@ -0,0 +1,82 @@
+<!-- SPDX-License-Identifier: 0BSD -->
+
+<template>
+ <transition name="fade-blur">
+ <div
+ v-if="show"
+ class="fixed inset-0 z-200 flex items-center justify-center bg-slate-900/45 dark:bg-black/55 backdrop-blur-xs px-4"
+ role="status"
+ aria-live="polite"
+ >
+ <div
+ class="w-full max-w-sm overflow-hidden rounded-2xl border border-gray-200/90 bg-white/95 shadow-xl dark:border-zinc-700/90 dark:bg-zinc-900/95"
+ >
+ <div class="px-6 pt-7 pb-1 text-center">
+ <div
+ class="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-slate-50 ring-1 ring-gray-200/80 dark:bg-zinc-950 dark:ring-zinc-700"
+ >
+ <img :src="logoUrl" alt="" class="h-9 w-9 object-contain p-1" />
+ </div>
+ <p class="text-xs font-medium text-gray-500 dark:text-zinc-500">
+ {{ $t("app.loading_overlay_kicker") }}
+ </p>
+ <h2 class="mt-1.5 text-lg font-semibold tracking-tight text-gray-900 dark:text-white">
+ {{ $t("app.loading_overlay_title") }}
+ </h2>
+ <p class="mt-3 text-sm leading-relaxed text-gray-600 dark:text-zinc-400">
+ {{ $t("app.loading_overlay_subtitle") }}
+ </p>
+ </div>
+ <div class="px-6 pb-7 pt-2">
+ <div class="h-1 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-zinc-800">
+ <div
+ class="identity-switch-indeterminate h-full w-1/3 rounded-full bg-blue-600 dark:bg-blue-500"
+ />
+ </div>
+ </div>
+ </div>
+ </div>
+ </transition>
+</template>
+
+<script>
+export default {
+ name: "AppIdentitySwitchOverlay",
+ props: {
+ show: {
+ type: Boolean,
+ default: false,
+ },
+ logoUrl: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<style>
+@keyframes identity-switch-indeterminate {
+ 0% {
+ transform: translateX(-100%);
+ }
+ 100% {
+ transform: translateX(350%);
+ }
+}
+
+.identity-switch-indeterminate {
+ animation: identity-switch-indeterminate 1.4s ease-in-out infinite;
+}
+
+.fade-blur-enter-active,
+.fade-blur-leave-active {
+ transition: all 0.5s ease;
+}
+
+.fade-blur-enter-from,
+.fade-blur-leave-to {
+ opacity: 0;
+ backdrop-filter: blur(0);
+}
+</style>
diff --git a/meshchatx/src/frontend/components/map/MapPage.vue b/meshchatx/src/frontend/components/map/MapPage.vue
index f207221c..8696cfa6 100644
--- a/meshchatx/src/frontend/components/map/MapPage.vue
+++ b/meshchatx/src/frontend/components/map/MapPage.vue
@@ -924,178 +924,34 @@
</div>
<!-- save drawing modal -->
- <div
- v-if="showSaveDrawingModal"
- class="fixed inset-0 z-100 flex items-center justify-center p-4 bg-black/40 backdrop-blur-xs"
- >
- <div
- class="bg-white dark:bg-zinc-900 w-full max-w-md rounded-2xl shadow-2xl overflow-hidden animate-in fade-in zoom-in duration-200"
- >
- <div class="p-6">
- <h2 class="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
- <MaterialDesignIcon icon-name="content-save-outline" class="size-6 text-blue-500" />
- {{ $t("map.save_drawing_title") }}
- </h2>
- <p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ $t("map.save_drawing_desc") }}</p>
-
- <div class="mt-6">
- <label
- class="block text-xs font-bold text-gray-500 dark:text-zinc-500 uppercase tracking-widest mb-2"
- >
- {{ $t("map.drawing_name") }}
- </label>
- <input
- ref="newDrawingNameInput"
- v-model="newDrawingName"
- type="text"
- class="w-full px-4 py-3 bg-gray-50 dark:bg-zinc-800 border-none rounded-xl text-sm focus:ring-2 focus:ring-blue-500 transition-all dark:text-white"
- :placeholder="$t('map.drawing_name_placeholder')"
- @keyup.enter="saveDrawing"
- />
- </div>
-
- <div class="mt-8 flex gap-3">
- <button
- type="button"
- class="flex-1 px-4 py-2.5 rounded-xl border border-gray-200 dark:border-zinc-700 text-sm font-semibold text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-zinc-800 transition"
- @click="showSaveDrawingModal = false"
- >
- {{ $t("common.close") }}
- </button>
- <button
- type="button"
- class="flex-1 px-4 py-2.5 rounded-xl bg-blue-600 text-white text-sm font-semibold shadow-lg shadow-blue-500/25 hover:bg-blue-500 transition active:scale-95 disabled:opacity-50"
- :disabled="!newDrawingName.trim()"
- @click="saveDrawing"
- >
- {{ $t("common.save") }}
- </button>
- </div>
- </div>
- </div>
- </div>
+ <MapSaveDrawingModal
+ ref="saveDrawingModal"
+ :show="showSaveDrawingModal"
+ :name="newDrawingName"
+ @update:name="newDrawingName = $event"
+ @close="showSaveDrawingModal = false"
+ @save="saveDrawing"
+ />
<!-- load drawing modal -->
- <div
- v-if="showLoadDrawingModal"
- class="fixed inset-0 z-100 flex items-center justify-center p-4 bg-black/40 backdrop-blur-xs"
- >
- <div
- class="bg-white dark:bg-zinc-900 w-full max-w-lg rounded-2xl shadow-2xl overflow-hidden animate-in fade-in zoom-in duration-200"
- >
- <div class="p-6">
- <div class="flex items-center justify-between mb-6">
- <h2 class="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
- <MaterialDesignIcon icon-name="folder-open-outline" class="size-6 text-blue-500" />
- {{ $t("map.load_drawing_title") }}
- </h2>
- <button class="text-gray-400 hover:text-gray-600" @click="showLoadDrawingModal = false">
- <MaterialDesignIcon icon-name="close" class="size-6" />
- </button>
- </div>
-
- <div v-if="isLoadingDrawings" class="py-12 flex flex-col items-center justify-center">
- <MaterialDesignIcon icon-name="loading" class="size-10 animate-spin text-blue-500 mb-4" />
- <span class="text-sm font-medium text-gray-500">{{ $t("map.loading_drawings") }}</span>
- </div>
-
- <div
- v-else-if="savedDrawings.length === 0"
- class="py-12 flex flex-col items-center justify-center text-center"
- >
- <div
- class="size-16 bg-gray-100 dark:bg-zinc-800 rounded-full flex items-center justify-center mb-4"
- >
- <MaterialDesignIcon icon-name="folder-outline" class="size-8 text-gray-400" />
- </div>
- <h3 class="text-lg font-bold text-gray-900 dark:text-white">{{ $t("map.no_drawings") }}</h3>
- <p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ $t("map.no_drawings_desc") }}</p>
- </div>
-
- <div v-else class="max-h-[400px] overflow-y-auto space-y-2 pr-2">
- <div
- v-for="drawing in savedDrawings"
- :key="drawing.id"
- class="group p-4 bg-gray-50 dark:bg-zinc-800/50 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-2xl border border-transparent hover:border-blue-200 dark:hover:border-blue-800 transition-all cursor-pointer flex items-center justify-between"
- @click="loadDrawing(drawing)"
- >
- <div class="flex-1 min-w-0 mr-4">
- <div class="font-bold text-gray-900 dark:text-white truncate">{{ drawing.name }}</div>
- <div class="text-xs text-gray-500 dark:text-zinc-500 mt-0.5">
- {{ $t("map.saved_on") }} {{ new Date(drawing.updated_at).toLocaleString() }}
- </div>
- </div>
- <button
- class="p-2 text-gray-400 hover:text-red-500 transition-colors"
- :title="$t('common.delete')"
- @click.stop="deleteDrawing(drawing)"
- >
- <MaterialDesignIcon icon-name="trash-can-outline" class="size-5" />
- </button>
- </div>
- </div>
-
- <div class="mt-8 flex justify-end">
- <button
- type="button"
- class="px-6 py-2.5 rounded-xl border border-gray-200 dark:border-zinc-700 text-sm font-semibold text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-zinc-800 transition"
- @click="showLoadDrawingModal = false"
- >
- {{ $t("common.close") }}
- </button>
- </div>
- </div>
- </div>
- </div>
+ <MapLoadDrawingModal
+ :show="showLoadDrawingModal"
+ :loading="isLoadingDrawings"
+ :drawings="savedDrawings"
+ @close="showLoadDrawingModal = false"
+ @load="loadDrawing"
+ @delete="deleteDrawing"
+ />
<!-- mobile note modal -->
- <transition name="fade">
- <div
- v-if="showNoteModal"
- class="fixed inset-0 z-100 flex items-end sm:items-center justify-center p-4 bg-black/50 backdrop-blur-xs"
- @click.self="closeNoteEditor"
- >
- <div
- class="bg-white dark:bg-zinc-900 w-full max-w-lg rounded-t-2xl sm:rounded-2xl shadow-2xl overflow-hidden animate-slide-up sm:animate-fade-in"
- >
- <div class="p-4 border-b border-gray-100 dark:border-zinc-800 flex items-center justify-between">
- <h3 class="text-lg font-bold text-gray-900 dark:text-white flex items-center gap-2">
- <MaterialDesignIcon icon-name="note-edit" class="size-5 text-amber-500" />
- Edit Note
- </h3>
- <button
- class="p-2 text-gray-400 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-full transition-colors"
- @click="closeNoteEditor"
- >
- <MaterialDesignIcon icon-name="close" class="size-5" />
- </button>
- </div>
- <div class="p-4">
- <textarea
- v-model="noteText"
- class="w-full h-40 p-4 text-base bg-gray-50 dark:bg-zinc-800 border border-gray-200 dark:border-zinc-700 rounded-xl focus:ring-2 focus:ring-amber-500 focus:border-transparent outline-hidden resize-none text-gray-900 dark:text-zinc-100"
- placeholder="Type your note here..."
- autofocus
- ></textarea>
- </div>
- <div class="p-4 bg-gray-50 dark:bg-zinc-800/50 flex justify-between gap-3">
- <button
- class="flex-1 px-4 py-3 text-sm font-bold text-red-500 hover:bg-red-100 dark:hover:bg-red-900/20 rounded-xl transition-colors flex items-center justify-center gap-2"
- @click="deleteNote"
- >
- <MaterialDesignIcon icon-name="trash-can-outline" class="size-5" />
- Delete
- </button>
- <button
- class="flex-2 px-4 py-3 text-sm font-bold bg-amber-500 text-white hover:bg-amber-600 rounded-xl shadow-lg shadow-amber-500/30 transition-colors"
- @click="saveNote"
- >
- Save Note
- </button>
- </div>
- </div>
- </div>
- </transition>
+ <MapMobileNoteModal
+ :show="showNoteModal"
+ :text="noteText"
+ @update:text="noteText = $event"
+ @close="closeNoteEditor"
+ @save="saveNote"
+ @delete="deleteNote"
+ />
</div>
</template>
@@ -1190,6 +1046,9 @@ import MapExportProgressPanel from "./internal/MapExportProgressPanel.vue";
import MapLoadingOverlay from "./internal/MapLoadingOverlay.vue";
import MapVectorExchangePanel from "./internal/MapVectorExchangePanel.vue";
import MapRemoteOverlayPanel from "./internal/MapRemoteOverlayPanel.vue";
+import MapSaveDrawingModal from "./internal/MapSaveDrawingModal.vue";
+import MapLoadDrawingModal from "./internal/MapLoadDrawingModal.vue";
+import MapMobileNoteModal from "./internal/MapMobileNoteModal.vue";
import { buildMeshchatMapUri, buildWebHashMapUrl } from "../../js/mapLinkUtils.js";
import { readGeoJsonToFeatures, writeFeaturesToGeoJson } from "../../js/mapExchange/geoJsonCodec.js";
import { readKmlToFeatures, writeFeaturesToKml } from "../../js/mapExchange/kmlCodec.js";
@@ -1228,6 +1087,9 @@ export default {
MapLoadingOverlay,
MapVectorExchangePanel,
MapRemoteOverlayPanel,
+ MapSaveDrawingModal,
+ MapLoadDrawingModal,
+ MapMobileNoteModal,
},
props: {
embedded: {
@@ -1502,7 +1364,7 @@ export default {
showSaveDrawingModal(val) {
if (val) {
this.$nextTick(() => {
- this.$refs.newDrawingNameInput?.focus();
+ this.$refs.saveDrawingModal?.focusNameInput?.();
});
}
},
diff --git a/meshchatx/src/frontend/components/map/internal/MapLoadDrawingModal.vue b/meshchatx/src/frontend/components/map/internal/MapLoadDrawingModal.vue
new file mode 100644
index 00000000..9eabdce0
--- /dev/null
+++ b/meshchatx/src/frontend/components/map/internal/MapLoadDrawingModal.vue
@@ -0,0 +1,98 @@
+<!-- SPDX-License-Identifier: 0BSD -->
+
+<template>
+ <div v-if="show" class="fixed inset-0 z-100 flex items-center justify-center p-4 bg-black/40 backdrop-blur-xs">
+ <div
+ class="bg-white dark:bg-zinc-900 w-full max-w-lg rounded-2xl shadow-2xl overflow-hidden animate-in fade-in zoom-in duration-200"
+ >
+ <div class="p-6">
+ <div class="flex items-center justify-between mb-6">
+ <h2 class="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
+ <MaterialDesignIcon icon-name="folder-open-outline" class="size-6 text-blue-500" />
+ {{ $t("map.load_drawing_title") }}
+ </h2>
+ <button class="text-gray-400 hover:text-gray-600" @click="$emit('close')">
+ <MaterialDesignIcon icon-name="close" class="size-6" />
+ </button>
+ </div>
+
+ <div v-if="loading" class="py-12 flex flex-col items-center justify-center">
+ <MaterialDesignIcon icon-name="loading" class="size-10 animate-spin text-blue-500 mb-4" />
+ <span class="text-sm font-medium text-gray-500">{{ $t("map.loading_drawings") }}</span>
+ </div>
+
+ <div
+ v-else-if="drawings.length === 0"
+ class="py-12 flex flex-col items-center justify-center text-center"
+ >
+ <div
+ class="size-16 bg-gray-100 dark:bg-zinc-800 rounded-full flex items-center justify-center mb-4"
+ >
+ <MaterialDesignIcon icon-name="folder-outline" class="size-8 text-gray-400" />
+ </div>
+ <h3 class="text-lg font-bold text-gray-900 dark:text-white">{{ $t("map.no_drawings") }}</h3>
+ <p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ $t("map.no_drawings_desc") }}</p>
+ </div>
+
+ <div v-else class="max-h-[400px] overflow-y-auto space-y-2 pr-2">
+ <div
+ v-for="drawing in drawings"
+ :key="drawing.id"
+ class="group p-4 bg-gray-50 dark:bg-zinc-800/50 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-2xl border border-transparent hover:border-blue-200 dark:hover:border-blue-800 transition-all cursor-pointer flex items-center justify-between"
+ @click="$emit('load', drawing)"
+ >
+ <div class="flex-1 min-w-0 mr-4">
+ <div class="font-bold text-gray-900 dark:text-white truncate">{{ drawing.name }}</div>
+ <div class="text-xs text-gray-500 dark:text-zinc-500 mt-0.5">
+ {{ $t("map.saved_on") }} {{ new Date(drawing.updated_at).toLocaleString() }}
+ </div>
+ </div>
+ <button
+ class="p-2 text-gray-400 hover:text-red-500 transition-colors"
+ :title="$t('common.delete')"
+ @click.stop="$emit('delete', drawing)"
+ >
+ <MaterialDesignIcon icon-name="trash-can-outline" class="size-5" />
+ </button>
+ </div>
+ </div>
+
+ <div class="mt-8 flex justify-end">
+ <button
+ type="button"
+ class="px-6 py-2.5 rounded-xl border border-gray-200 dark:border-zinc-700 text-sm font-semibold text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-zinc-800 transition"
+ @click="$emit('close')"
+ >
+ {{ $t("common.close") }}
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+import MaterialDesignIcon from "../../MaterialDesignIcon.vue";
+
+export default {
+ name: "MapLoadDrawingModal",
+ components: {
+ MaterialDesignIcon,
+ },
+ props: {
+ show: {
+ type: Boolean,
+ default: false,
+ },
+ loading: {
+ type: Boolean,
+ default: false,
+ },
+ drawings: {
+ type: Array,
+ default: () => [],
+ },
+ },
+ emits: ["close", "load", "delete"],
+};
+</script>
diff --git a/meshchatx/src/frontend/components/map/internal/MapMobileNoteModal.vue b/meshchatx/src/frontend/components/map/internal/MapMobileNoteModal.vue
new file mode 100644
index 00000000..15381f87
--- /dev/null
+++ b/meshchatx/src/frontend/components/map/internal/MapMobileNoteModal.vue
@@ -0,0 +1,74 @@
+<!-- SPDX-License-Identifier: 0BSD -->
+
+<template>
+ <transition name="fade">
+ <div
+ v-if="show"
+ class="fixed inset-0 z-100 flex items-end sm:items-center justify-center p-4 bg-black/50 backdrop-blur-xs"
+ @click.self="$emit('close')"
+ >
+ <div
+ class="bg-white dark:bg-zinc-900 w-full max-w-lg rounded-t-2xl sm:rounded-2xl shadow-2xl overflow-hidden animate-slide-up sm:animate-fade-in"
+ >
+ <div class="p-4 border-b border-gray-100 dark:border-zinc-800 flex items-center justify-between">
+ <h3 class="text-lg font-bold text-gray-900 dark:text-white flex items-center gap-2">
+ <MaterialDesignIcon icon-name="note-edit" class="size-5 text-amber-500" />
+ Edit Note
+ </h3>
+ <button
+ class="p-2 text-gray-400 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-full transition-colors"
+ @click="$emit('close')"
+ >
+ <MaterialDesignIcon icon-name="close" class="size-5" />
+ </button>
+ </div>
+ <div class="p-4">
+ <textarea
+ :value="text"
+ class="w-full h-40 p-4 text-base bg-gray-50 dark:bg-zinc-800 border border-gray-200 dark:border-zinc-700 rounded-xl focus:ring-2 focus:ring-amber-500 focus:border-transparent outline-hidden resize-none text-gray-900 dark:text-zinc-100"
+ placeholder="Type your note here..."
+ autofocus
+ @input="$emit('update:text', $event.target.value)"
+ ></textarea>
+ </div>
+ <div class="p-4 bg-gray-50 dark:bg-zinc-800/50 flex justify-between gap-3">
+ <button
+ class="flex-1 px-4 py-3 text-sm font-bold text-red-500 hover:bg-red-100 dark:hover:bg-red-900/20 rounded-xl transition-colors flex items-center justify-center gap-2"
+ @click="$emit('delete')"
+ >
+ <MaterialDesignIcon icon-name="trash-can-outline" class="size-5" />
+ Delete
+ </button>
+ <button
+ class="flex-2 px-4 py-3 text-sm font-bold bg-amber-500 text-white hover:bg-amber-600 rounded-xl shadow-lg shadow-amber-500/30 transition-colors"
+ @click="$emit('save')"
+ >
+ Save Note
+ </button>
+ </div>
+ </div>
+ </div>
+ </transition>
+</template>
+
+<script>
+import MaterialDesignIcon from "../../MaterialDesignIcon.vue";
+
+export default {
+ name: "MapMobileNoteModal",
+ components: {
+ MaterialDesignIcon,
+ },
+ props: {
+ show: {
+ type: Boolean,
+ default: false,
+ },
+ text: {
+ type: String,
+ default: "",
+ },
+ },
+ emits: ["close", "save", "delete", "update:text"],
+};
+</script>
diff --git a/meshchatx/src/frontend/components/map/internal/MapSaveDrawingModal.vue b/meshchatx/src/frontend/components/map/internal/MapSaveDrawingModal.vue
new file mode 100644
index 00000000..aad24cc9
--- /dev/null
+++ b/meshchatx/src/frontend/components/map/internal/MapSaveDrawingModal.vue
@@ -0,0 +1,81 @@
+<!-- SPDX-License-Identifier: 0BSD -->
+
+<template>
+ <div v-if="show" class="fixed inset-0 z-100 flex items-center justify-center p-4 bg-black/40 backdrop-blur-xs">
+ <div
+ class="bg-white dark:bg-zinc-900 w-full max-w-md rounded-2xl shadow-2xl overflow-hidden animate-in fade-in zoom-in duration-200"
+ >
+ <div class="p-6">
+ <h2 class="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
+ <MaterialDesignIcon icon-name="content-save-outline" class="size-6 text-blue-500" />
+ {{ $t("map.save_drawing_title") }}
+ </h2>
+ <p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ $t("map.save_drawing_desc") }}</p>
+
+ <div class="mt-6">
+ <label
+ class="block text-xs font-bold text-gray-500 dark:text-zinc-500 uppercase tracking-widest mb-2"
+ >
+ {{ $t("map.drawing_name") }}
+ </label>
+ <input
+ ref="newDrawingNameInput"
+ :value="name"
+ type="text"
+ class="w-full px-4 py-3 bg-gray-50 dark:bg-zinc-800 border-none rounded-xl text-sm focus:ring-2 focus:ring-blue-500 transition-all dark:text-white"
+ :placeholder="$t('map.drawing_name_placeholder')"
+ @input="$emit('update:name', $event.target.value)"
+ @keyup.enter="$emit('save')"
+ />
+ </div>
+
+ <div class="mt-8 flex gap-3">
+ <button
+ type="button"
+ class="flex-1 px-4 py-2.5 rounded-xl border border-gray-200 dark:border-zinc-700 text-sm font-semibold text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-zinc-800 transition"
+ @click="$emit('close')"
+ >
+ {{ $t("common.close") }}
+ </button>
+ <button
+ type="button"
+ class="flex-1 px-4 py-2.5 rounded-xl bg-blue-600 text-white text-sm font-semibold shadow-lg shadow-blue-500/25 hover:bg-blue-500 transition active:scale-95 disabled:opacity-50"
+ :disabled="!String(name || '').trim()"
+ @click="$emit('save')"
+ >
+ {{ $t("common.save") }}
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+import MaterialDesignIcon from "../../MaterialDesignIcon.vue";
+
+export default {
+ name: "MapSaveDrawingModal",
+ components: {
+ MaterialDesignIcon,
+ },
+ props: {
+ show: {
+ type: Boolean,
+ default: false,
+ },
+ name: {
+ type: String,
+ default: "",
+ },
+ },
+ emits: ["close", "save", "update:name"],
+ methods: {
+ focusNameInput() {
+ this.$nextTick(() => {
+ this.$refs.newDrawingNameInput?.focus?.();
+ });
+ },
+ },
+};
+</script>
diff --git a/meshchatx/src/frontend/components/messages/ConversationMessageEntry.vue b/meshchatx/src/frontend/components/messages/ConversationMessageEntry.vue
index 3e50f257..a0d0499a 100644
--- a/meshchatx/src/frontend/components/messages/ConversationMessageEntry.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationMessageEntry.vue
@@ -1390,7 +1390,7 @@
<script>
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import AudioWaveformPlayer from "./AudioWaveformPlayer.vue";
-import OutboundTransferProgressFooter from "./OutboundTransferProgressFooter.vue";
+import OutboundTransferProgressFooter from "./outbound/OutboundTransferProgressFooter.vue";
import MessageReactionsOverlay from "./MessageReactionsOverlay.vue";
import StickerView from "../stickers/StickerView.vue";
import InViewAnimatedImg from "./InViewAnimatedImg.vue";
diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
index 629558ef..715053c8 100644
--- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
@@ -59,82 +59,16 @@
/>
<!-- Share Contact Modal -->
- <div
- v-if="isShareContactModalOpen"
- class="fixed inset-0 z-100 flex items-center justify-center p-4 pt-[max(0.75rem,env(safe-area-inset-top))] pb-[max(0.75rem,env(safe-area-inset-bottom))] bg-black/50 backdrop-blur-xs"
- @click.self="isShareContactModalOpen = false"
- >
- <div
- class="w-full max-w-md bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl overflow-hidden max-h-[min(90dvh,40rem)] flex flex-col"
- >
- <div class="px-6 py-4 border-b border-gray-100 dark:border-zinc-800 flex items-center justify-between">
- <h3 class="text-lg font-bold text-gray-900 dark:text-white">
- {{ $t("messages.share_contact_modal_title") }}
- </h3>
- <button
- type="button"
- class="text-gray-400 hover:text-gray-500 dark:hover:text-zinc-300 transition-colors"
- @click="isShareContactModalOpen = false"
- >
- <MaterialDesignIcon icon-name="close" class="size-6" />
- </button>
- </div>
- <div class="p-6">
- <div class="mb-4">
- <div class="relative">
- <input
- v-model="contactsSearch"
- type="text"
- :placeholder="$t('messages.share_contact_search_placeholder')"
- class="block w-full rounded-lg border-0 py-2 pl-10 text-gray-900 dark:text-white shadow-xs ring-1 ring-inset ring-gray-300 dark:ring-zinc-800 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm dark:bg-zinc-900"
- />
- <div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
- <MaterialDesignIcon icon-name="magnify" class="size-5 text-gray-400" />
- </div>
- </div>
- </div>
- <div class="max-h-64 overflow-y-auto space-y-2">
- <button
- v-for="contact in filteredContacts"
- :key="contact.id"
- type="button"
- class="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-gray-50 dark:hover:bg-zinc-800 transition-colors text-left"
- @click="shareContact(contact)"
- >
- <div class="shrink-0">
- <LxmfUserIcon
- :custom-image="contact.custom_image"
- :icon-name="lxmfContactResolvedIcon(contact).iconName"
- :icon-foreground-colour="lxmfContactResolvedIcon(contact).foreground"
- :icon-background-colour="lxmfContactResolvedIcon(contact).background"
- icon-class="size-10"
- />
- </div>
- <div class="min-w-0">
- <div class="font-bold text-gray-900 dark:text-white truncate">
- {{ contact.name }}
- </div>
- <div class="text-[10px] text-gray-500 dark:text-zinc-500 font-mono truncate">
- {{ lxmfDeliveryDestinationHexFromContact(contact) || contact.remote_identity_hash }}
- </div>
- <div
- v-if="contact.lxmf_address"
- class="text-[9px] text-gray-400 dark:text-zinc-500 font-mono truncate"
- >
- LXMF: {{ contact.lxmf_address }}
- </div>
- <div
- v-if="contact.lxst_address"
- class="text-[9px] text-gray-400 dark:text-zinc-500 font-mono truncate"
- >
- LXST: {{ contact.lxst_address }}
- </div>
- </div>
- </button>
- </div>
- </div>
- </div>
- </div>
+ <ShareContactModal
+ :show="isShareContactModalOpen"
+ :search="contactsSearch"
+ :contacts="filteredContacts"
+ :resolve-icon="lxmfContactResolvedIcon"
+ :destination-hex="lxmfDeliveryDestinationHexFromContact"
+ @update:search="contactsSearch = $event"
+ @close="isShareContactModalOpen = false"
+ @share="shareContact"
+ />
<div
class="flex flex-col flex-1 min-h-0 min-w-0 relative"
@@ -1304,73 +1238,16 @@
</div>
<!-- image modal -->
- <Transition
- enter-active-class="transition ease-out duration-200"
- enter-from-class="opacity-0"
- enter-to-class="opacity-100"
- leave-active-class="transition ease-in duration-150"
- leave-from-class="opacity-100"
- leave-to-class="opacity-0"
- >
- <div
- v-if="imageModalUrl"
- ref="imageModalOverlay"
- tabindex="0"
- class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 dark:bg-black/90 backdrop-blur-xs p-4 pt-[max(1rem,env(safe-area-inset-top))] pb-[max(1rem,env(safe-area-inset-bottom))] outline-hidden"
- @click="closeImageModal"
- @keydown.left.prevent="imageModalNavigate(-1)"
- @keydown.right.prevent="imageModalNavigate(1)"
- @keydown.escape.prevent="closeImageModal"
- >
- <div class="relative max-w-7xl max-h-full group/image-modal" @click.stop>
- <button
- type="button"
- class="absolute -top-12 left-0 inline-flex items-center justify-center w-10 h-10 rounded-xl bg-white/10 dark:bg-zinc-900/10 hover:bg-white/20 dark:hover:bg-zinc-900/20 text-white transition-colors opacity-0 group-hover/image-modal:opacity-100 focus:opacity-100"
- :title="$t('messages.save_image_to_device')"
- @click="downloadImageModalCurrent"
- >
- <MaterialDesignIcon icon-name="download" class="size-5" />
- </button>
- <button
- type="button"
- class="absolute -top-12 right-0 inline-flex items-center justify-center w-10 h-10 rounded-xl bg-white/10 dark:bg-zinc-900/10 hover:bg-white/20 dark:hover:bg-zinc-900/20 text-white transition-colors"
- @click="closeImageModal"
- >
- <MaterialDesignIcon icon-name="close" class="size-5" />
- </button>
- <button
- v-if="imageModalGallery && imageModalGallery.length > 1"
- type="button"
- class="absolute left-0 top-1/2 z-10 -translate-y-1/2 inline-flex items-center justify-center w-11 h-11 rounded-full bg-black/40 hover:bg-black/55 text-white transition-colors"
- aria-label="Previous image"
- @click.stop="imageModalNavigate(-1)"
- >
- <MaterialDesignIcon icon-name="chevron-left" class="size-7" />
- </button>
- <button
- v-if="imageModalGallery && imageModalGallery.length > 1"
- type="button"
- class="absolute right-0 top-1/2 z-10 -translate-y-1/2 inline-flex items-center justify-center w-11 h-11 rounded-full bg-black/40 hover:bg-black/55 text-white transition-colors"
- aria-label="Next image"
- @click.stop="imageModalNavigate(1)"
- >
- <MaterialDesignIcon icon-name="chevron-right" class="size-7" />
- </button>
- <div
- v-if="imageModalGallery && imageModalGallery.length > 1"
- class="pointer-events-none absolute bottom-2 left-1/2 z-10 -translate-x-1/2 rounded-full bg-black/50 px-3 py-1 text-xs font-medium text-white"
- >
- {{ imageModalIndex + 1 }} / {{ imageModalGallery.length }}
- </div>
- <img
- :src="imageModalUrl"
- class="max-w-full max-h-[90vh] rounded-xl shadow-2xl"
- alt="Image preview"
- @contextmenu.prevent.stop="onImageModalContextMenu"
- />
- </div>
- </div>
- </Transition>
+ <ConversationImageModal
+ ref="imageModal"
+ :url="imageModalUrl || ''"
+ :gallery="imageModalGallery"
+ :index="imageModalIndex"
+ @close="closeImageModal"
+ @navigate="imageModalNavigate"
+ @download="downloadImageModalCurrent"
+ @contextmenu="onImageModalContextMenu"
+ />
<Teleport to="body">
<ContextMenuPanel
@@ -1812,24 +1689,34 @@ import {
} from "../../js/reticulumPathfinding.js";
import MicrophoneRecorder from "../../js/MicrophoneRecorder";
import WebSocketConnection from "../../js/WebSocketConnection";
-import AddAudioButton from "./AddAudioButton.vue";
+import AddAudioButton from "./composer/AddAudioButton.vue";
import { fromNow } from "../../libs/datetime.js";
const SCROLL_SETTLE_MAX_PASSES = 24;
const OPEN_CONVERSATION_SCROLL_PIN_MS = 900;
-import SendMessageButton from "./SendMessageButton.vue";
+import SendMessageButton from "./composer/SendMessageButton.vue";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import ContextMenuDivider from "../contextmenu/ContextMenuDivider.vue";
import ContextMenuItem from "../contextmenu/ContextMenuItem.vue";
import ContextMenuPanel from "../contextmenu/ContextMenuPanel.vue";
-import AddImageButton from "./AddImageButton.vue";
+import AddImageButton from "./composer/AddImageButton.vue";
import AudioWaveformPlayer from "./AudioWaveformPlayer.vue";
import LxmfUserIcon from "../LxmfUserIcon.vue";
import GlobalEmitter from "../../js/GlobalEmitter";
import ToastUtils from "../../js/ToastUtils";
import NotificationUtils from "../../js/NotificationUtils";
-import PaperMessageModal from "./PaperMessageModal.vue";
+import PaperMessageModal from "./modals/PaperMessageModal.vue";
+import ShareContactModal from "./modals/ShareContactModal.vue";
+import ConversationImageModal from "./modals/ConversationImageModal.vue";
+import {
+ lxmfContactResolvedIcon as lxmfContactResolvedIconHelper,
+ lxmfDeliveryDestinationHexFromContact as lxmfDeliveryDestinationHexFromContactHelper,
+} from "./lxmf/contactDisplay.js";
+import {
+ normalizeLxmfMessage as normalizeLxmfMessageHelper,
+ normalizeSidebandCommandKey as normalizeSidebandCommandKeyHelper,
+} from "./lxmf/normalize.js";
import GlobalState from "../../js/GlobalState";
import MarkdownRenderer from "../../js/MarkdownRenderer";
import { handleRichHtmlLinkClick } from "../../js/NomadRichHtmlLinks.js";
@@ -1862,6 +1749,8 @@ export default {
AddAudioButton,
AudioWaveformPlayer,
PaperMessageModal,
+ ShareContactModal,
+ ConversationImageModal,
LxmfUserIcon,
ConversationMessageEntry,
ConversationMessageListVirtual,
@@ -3675,14 +3564,7 @@ export default {
GlobalEmitter.emit("compose-new-message", destinationHash);
},
normalizeLxmfMessage(msg, isOutbound) {
- const normalized = { ...msg };
- if (!normalized.created_at && normalized.timestamp) {
- normalized.created_at = new Date(normalized.timestamp * 1000).toISOString();
- }
- if (isOutbound && normalized.state === "unknown") {
- normalized.state = "outbound";
- }
- return normalized;
+ return normalizeLxmfMessageHelper(msg, isOutbound);
},
_hexEqual(a, b) {
return (a || "").toLowerCase() === (b || "").toLowerCase();
@@ -4610,7 +4492,7 @@ export default {
Array.isArray(galleryChatItems) && galleryChatItems.length > 0 ? galleryChatItems[0] : null;
}
this.$nextTick(() => {
- this.$refs.imageModalOverlay?.focus?.();
+ this.$refs.imageModal?.focusOverlay?.();
});
},
closeImageModal() {
@@ -5554,45 +5436,10 @@ export default {
this.sendMessage();
},
lxmfDeliveryDestinationHexFromContact(contact) {
- if (!contact) return "";
- const order = [contact.remote_destination_hash, contact.lxmf_address, contact.remote_identity_hash];
- for (const c of order) {
- const h = Utils.normalizeMeshchatHashHex(c);
- if (h) {
- return h;
- }
- }
- return "";
+ return lxmfDeliveryDestinationHexFromContactHelper(contact);
},
lxmfContactResolvedIcon(contact) {
- const empty = { iconName: "", foreground: "", background: "" };
- if (!contact) {
- return empty;
- }
- const ri = contact.remote_icon;
- if (ri?.icon_name) {
- return {
- iconName: ri.icon_name,
- foreground: ri.foreground_colour || "",
- background: ri.background_colour || "",
- };
- }
- const dest = this.lxmfDeliveryDestinationHexFromContact(contact);
- if (!dest) {
- return empty;
- }
- const conv =
- this.conversations.find((c) => Utils.normalizeMeshchatHashHex(c.destination_hash || "") === dest) ||
- null;
- const lu = conv?.lxmf_user_icon;
- if (lu?.icon_name) {
- return {
- iconName: lu.icon_name,
- foreground: lu.foreground_colour || "",
- background: lu.background_colour || "",
- };
- }
- return empty;
+ return lxmfContactResolvedIconHelper(contact, this.conversations);
},
shareAsPaperMessage(chatItem) {
this.paperMessageHash = chatItem.lxmf_message.hash;
@@ -6081,32 +5928,7 @@ export default {
}
},
normalizeSidebandCommandKey(key) {
- const raw = String(key ?? "").trim();
- if (!raw) {
- return null;
- }
- const known = {
- plugin: "0x00",
- telemetry_request: "0x01",
- request: "0x01",
- location_request: "0x01",
- ping: "0x02",
- echo: "0x03",
- signal_report: "0x04",
- };
- if (Object.prototype.hasOwnProperty.call(known, raw.toLowerCase())) {
- return known[raw.toLowerCase()];
- }
- if (/^\d+$/.test(raw)) {
- const n = Number.parseInt(raw, 10);
- if (Number.isInteger(n) && n >= 0 && n <= 255) {
- return `0x${n.toString(16).padStart(2, "0")}`;
- }
- }
- if (/^0x[0-9a-f]{1,2}$/i.test(raw)) {
- return `0x${raw.slice(2).toLowerCase().padStart(2, "0")}`;
- }
- return null;
+ return normalizeSidebandCommandKeyHelper(key);
},
parseCommandOrRequestText(text) {
const trimmed = String(text || "").trim();
diff --git a/meshchatx/src/frontend/components/messages/AddAudioButton.vue b/meshchatx/src/frontend/components/messages/composer/AddAudioButton.vue
similarity index 98%
rename from meshchatx/src/frontend/components/messages/AddAudioButton.vue
rename to meshchatx/src/frontend/components/messages/composer/AddAudioButton.vue
index 4336f698..ec702a44 100644
--- a/meshchatx/src/frontend/components/messages/AddAudioButton.vue
+++ b/meshchatx/src/frontend/components/messages/composer/AddAudioButton.vue
@@ -67,7 +67,7 @@
</template>
<script>
-import MaterialDesignIcon from "../MaterialDesignIcon.vue";
+import MaterialDesignIcon from "../../MaterialDesignIcon.vue";
export default {
name: "AddAudioButton",
components: {
diff --git a/meshchatx/src/frontend/components/messages/AddImageButton.vue b/meshchatx/src/frontend/components/messages/composer/AddImageButton.vue
similarity index 98%
rename from meshchatx/src/frontend/components/messages/AddImageButton.vue
rename to meshchatx/src/frontend/components/messages/composer/AddImageButton.vue
index 722e1e58..c9047119 100644
--- a/meshchatx/src/frontend/components/messages/AddImageButton.vue
+++ b/meshchatx/src/frontend/components/messages/composer/AddImageButton.vue
@@ -66,8 +66,8 @@
<script>
import Compressor from "compressorjs";
-import DialogUtils from "../../js/DialogUtils";
-import MaterialDesignIcon from "../MaterialDesignIcon.vue";
+import DialogUtils from "../../../js/DialogUtils";
+import MaterialDesignIcon from "../../MaterialDesignIcon.vue";
export default {
name: "AddImageButton",
components: {
diff --git a/meshchatx/src/frontend/components/messages/SendMessageButton.vue b/meshchatx/src/frontend/components/messages/composer/SendMessageButton.vue
similarity index 100%
rename from meshchatx/src/frontend/components/messages/SendMessageButton.vue
rename to meshchatx/src/frontend/components/messages/composer/SendMessageButton.vue
diff --git a/meshchatx/src/frontend/components/messages/lxmf/contactDisplay.js b/meshchatx/src/frontend/components/messages/lxmf/contactDisplay.js
new file mode 100644
index 00000000..be62af38
--- /dev/null
+++ b/meshchatx/src/frontend/components/messages/lxmf/contactDisplay.js
@@ -0,0 +1,45 @@
+// SPDX-License-Identifier: 0BSD
+
+import Utils from "../../../js/Utils.js";
+
+export function lxmfDeliveryDestinationHexFromContact(contact) {
+ if (!contact) return "";
+ const order = [contact.remote_destination_hash, contact.lxmf_address, contact.remote_identity_hash];
+ for (const c of order) {
+ const h = Utils.normalizeMeshchatHashHex(c);
+ if (h) {
+ return h;
+ }
+ }
+ return "";
+}
+
+export function lxmfContactResolvedIcon(contact, conversations = []) {
+ const empty = { iconName: "", foreground: "", background: "" };
+ if (!contact) {
+ return empty;
+ }
+ const ri = contact.remote_icon;
+ if (ri?.icon_name) {
+ return {
+ iconName: ri.icon_name,
+ foreground: ri.foreground_colour || "",
+ background: ri.background_colour || "",
+ };
+ }
+ const dest = lxmfDeliveryDestinationHexFromContact(contact);
+ if (!dest) {
+ return empty;
+ }
+ const conv =
+ (conversations || []).find((c) => Utils.normalizeMeshchatHashHex(c.destination_hash || "") === dest) || null;
+ const lu = conv?.lxmf_user_icon;
+ if (lu?.icon_name) {
+ return {
+ iconName: lu.icon_name,
+ foreground: lu.foreground_colour || "",
+ background: lu.background_colour || "",
+ };
+ }
+ return empty;
+}
diff --git a/meshchatx/src/frontend/components/messages/lxmf/normalize.js b/meshchatx/src/frontend/components/messages/lxmf/normalize.js
new file mode 100644
index 00000000..2b07e4f7
--- /dev/null
+++ b/meshchatx/src/frontend/components/messages/lxmf/normalize.js
@@ -0,0 +1,41 @@
+// SPDX-License-Identifier: 0BSD
+
+export function normalizeLxmfMessage(msg, isOutbound) {
+ const normalized = { ...msg };
+ if (!normalized.created_at && normalized.timestamp) {
+ normalized.created_at = new Date(normalized.timestamp * 1000).toISOString();
+ }
+ if (isOutbound && normalized.state === "unknown") {
+ normalized.state = "outbound";
+ }
+ return normalized;
+}
+
+export function normalizeSidebandCommandKey(key) {
+ const raw = String(key ?? "").trim();
+ if (!raw) {
+ return null;
+ }
+ const known = {
+ plugin: "0x00",
+ telemetry_request: "0x01",
+ request: "0x01",
+ location_request: "0x01",
+ ping: "0x02",
+ echo: "0x03",
+ signal_report: "0x04",
+ };
+ if (Object.prototype.hasOwnProperty.call(known, raw.toLowerCase())) {
+ return known[raw.toLowerCase()];
+ }
+ if (/^\d+$/.test(raw)) {
+ const n = Number.parseInt(raw, 10);
+ if (Number.isInteger(n) && n >= 0 && n <= 255) {
+ return `0x${n.toString(16).padStart(2, "0")}`;
+ }
+ }
+ if (/^0x[0-9a-f]{1,2}$/i.test(raw)) {
+ return `0x${raw.slice(2).toLowerCase().padStart(2, "0")}`;
+ }
+ return null;
+}
diff --git a/meshchatx/src/frontend/components/messages/modals/ConversationImageModal.vue b/meshchatx/src/frontend/components/messages/modals/ConversationImageModal.vue
new file mode 100644
index 00000000..39d9ed8c
--- /dev/null
+++ b/meshchatx/src/frontend/components/messages/modals/ConversationImageModal.vue
@@ -0,0 +1,104 @@
+<!-- SPDX-License-Identifier: 0BSD -->
+
+<template>
+ <Transition
+ enter-active-class="transition ease-out duration-200"
+ enter-from-class="opacity-0"
+ enter-to-class="opacity-100"
+ leave-active-class="transition ease-in duration-150"
+ leave-from-class="opacity-100"
+ leave-to-class="opacity-0"
+ >
+ <div
+ v-if="url"
+ ref="imageModalOverlay"
+ tabindex="0"
+ class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 dark:bg-black/90 backdrop-blur-xs p-4 pt-[max(1rem,env(safe-area-inset-top))] pb-[max(1rem,env(safe-area-inset-bottom))] outline-hidden"
+ @click="$emit('close')"
+ @keydown.left.prevent="$emit('navigate', -1)"
+ @keydown.right.prevent="$emit('navigate', 1)"
+ @keydown.escape.prevent="$emit('close')"
+ >
+ <div class="relative max-w-7xl max-h-full group/image-modal" @click.stop>
+ <button
+ type="button"
+ class="absolute -top-12 left-0 inline-flex items-center justify-center w-10 h-10 rounded-xl bg-white/10 dark:bg-zinc-900/10 hover:bg-white/20 dark:hover:bg-zinc-900/20 text-white transition-colors opacity-0 group-hover/image-modal:opacity-100 focus:opacity-100"
+ :title="$t('messages.save_image_to_device')"
+ @click="$emit('download')"
+ >
+ <MaterialDesignIcon icon-name="download" class="size-5" />
+ </button>
+ <button
+ type="button"
+ class="absolute -top-12 right-0 inline-flex items-center justify-center w-10 h-10 rounded-xl bg-white/10 dark:bg-zinc-900/10 hover:bg-white/20 dark:hover:bg-zinc-900/20 text-white transition-colors"
+ @click="$emit('close')"
+ >
+ <MaterialDesignIcon icon-name="close" class="size-5" />
+ </button>
+ <button
+ v-if="gallery && gallery.length > 1"
+ type="button"
+ class="absolute left-0 top-1/2 z-10 -translate-y-1/2 inline-flex items-center justify-center w-11 h-11 rounded-full bg-black/40 hover:bg-black/55 text-white transition-colors"
+ aria-label="Previous image"
+ @click.stop="$emit('navigate', -1)"
+ >
+ <MaterialDesignIcon icon-name="chevron-left" class="size-7" />
+ </button>
+ <button
+ v-if="gallery && gallery.length > 1"
+ type="button"
+ class="absolute right-0 top-1/2 z-10 -translate-y-1/2 inline-flex items-center justify-center w-11 h-11 rounded-full bg-black/40 hover:bg-black/55 text-white transition-colors"
+ aria-label="Next image"
+ @click.stop="$emit('navigate', 1)"
+ >
+ <MaterialDesignIcon icon-name="chevron-right" class="size-7" />
+ </button>
+ <div
+ v-if="gallery && gallery.length > 1"
+ class="pointer-events-none absolute bottom-2 left-1/2 z-10 -translate-x-1/2 rounded-full bg-black/50 px-3 py-1 text-xs font-medium text-white"
+ >
+ {{ index + 1 }} / {{ gallery.length }}
+ </div>
+ <img
+ :src="url"
+ class="max-w-full max-h-[90vh] rounded-xl shadow-2xl"
+ alt="Image preview"
+ @contextmenu.prevent.stop="$emit('contextmenu', $event)"
+ />
+ </div>
+ </div>
+ </Transition>
+</template>
+
+<script>
+import MaterialDesignIcon from "../../MaterialDesignIcon.vue";
+
+export default {
+ name: "ConversationImageModal",
+ components: {
+ MaterialDesignIcon,
+ },
+ props: {
+ url: {
+ type: String,
+ default: "",
+ },
+ gallery: {
+ type: Array,
+ default: null,
+ },
+ index: {
+ type: Number,
+ default: 0,
+ },
+ },
+ emits: ["close", "navigate", "download", "contextmenu"],
+ methods: {
+ focusOverlay() {
+ this.$nextTick(() => {
+ this.$refs.imageModalOverlay?.focus?.();
+ });
+ },
+ },
+};
+</script>
diff --git a/meshchatx/src/frontend/components/messages/PaperMessageModal.vue b/meshchatx/src/frontend/components/messages/modals/PaperMessageModal.vue
similarity index 99%
rename from meshchatx/src/frontend/components/messages/PaperMessageModal.vue
rename to meshchatx/src/frontend/components/messages/modals/PaperMessageModal.vue
index cd1adebb..6623435b 100644
--- a/meshchatx/src/frontend/components/messages/PaperMessageModal.vue
+++ b/meshchatx/src/frontend/components/messages/modals/PaperMessageModal.vue
@@ -173,8 +173,8 @@
<script>
import QRCode from "qrcode";
-import MaterialDesignIcon from "../MaterialDesignIcon.vue";
-import ToastUtils from "../../js/ToastUtils";
+import MaterialDesignIcon from "../../MaterialDesignIcon.vue";
+import ToastUtils from "../../../js/ToastUtils";
export default {
name: "PaperMessageModal",
diff --git a/meshchatx/src/frontend/components/messages/modals/ShareContactModal.vue b/meshchatx/src/frontend/components/messages/modals/ShareContactModal.vue
new file mode 100644
index 00000000..a6dcf7a1
--- /dev/null
+++ b/meshchatx/src/frontend/components/messages/modals/ShareContactModal.vue
@@ -0,0 +1,117 @@
+<!-- SPDX-License-Identifier: 0BSD -->
+
+<template>
+ <div
+ v-if="show"
+ class="fixed inset-0 z-100 flex items-center justify-center p-4 pt-[max(0.75rem,env(safe-area-inset-top))] pb-[max(0.75rem,env(safe-area-inset-bottom))] bg-black/50 backdrop-blur-xs"
+ @click.self="$emit('close')"
+ >
+ <div
+ class="w-full max-w-md bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl overflow-hidden max-h-[min(90dvh,40rem)] flex flex-col"
+ >
+ <div class="px-6 py-4 border-b border-gray-100 dark:border-zinc-800 flex items-center justify-between">
+ <h3 class="text-lg font-bold text-gray-900 dark:text-white">
+ {{ $t("messages.share_contact_modal_title") }}
+ </h3>
+ <button
+ type="button"
+ class="text-gray-400 hover:text-gray-500 dark:hover:text-zinc-300 transition-colors"
+ @click="$emit('close')"
+ >
+ <MaterialDesignIcon icon-name="close" class="size-6" />
+ </button>
+ </div>
+ <div class="p-6">
+ <div class="mb-4">
+ <div class="relative">
+ <input
+ :value="search"
+ type="text"
+ :placeholder="$t('messages.share_contact_search_placeholder')"
+ class="block w-full rounded-lg border-0 py-2 pl-10 text-gray-900 dark:text-white shadow-xs ring-1 ring-inset ring-gray-300 dark:ring-zinc-800 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm dark:bg-zinc-900"
+ @input="$emit('update:search', $event.target.value)"
+ />
+ <div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
+ <MaterialDesignIcon icon-name="magnify" class="size-5 text-gray-400" />
+ </div>
+ </div>
+ </div>
+ <div class="max-h-64 overflow-y-auto space-y-2">
+ <button
+ v-for="contact in contacts"
+ :key="contact.id"
+ type="button"
+ class="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-gray-50 dark:hover:bg-zinc-800 transition-colors text-left"
+ @click="$emit('share', contact)"
+ >
+ <div class="shrink-0">
+ <LxmfUserIcon
+ :custom-image="contact.custom_image"
+ :icon-name="resolveIcon(contact).iconName"
+ :icon-foreground-colour="resolveIcon(contact).foreground"
+ :icon-background-colour="resolveIcon(contact).background"
+ icon-class="size-10"
+ />
+ </div>
+ <div class="min-w-0">
+ <div class="font-bold text-gray-900 dark:text-white truncate">
+ {{ contact.name }}
+ </div>
+ <div class="text-[10px] text-gray-500 dark:text-zinc-500 font-mono truncate">
+ {{ destinationHex(contact) || contact.remote_identity_hash }}
+ </div>
+ <div
+ v-if="contact.lxmf_address"
+ class="text-[9px] text-gray-400 dark:text-zinc-500 font-mono truncate"
+ >
+ LXMF: {{ contact.lxmf_address }}
+ </div>
+ <div
+ v-if="contact.lxst_address"
+ class="text-[9px] text-gray-400 dark:text-zinc-500 font-mono truncate"
+ >
+ LXST: {{ contact.lxst_address }}
+ </div>
+ </div>
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+import MaterialDesignIcon from "../../MaterialDesignIcon.vue";
+import LxmfUserIcon from "../../LxmfUserIcon.vue";
+
+export default {
+ name: "ShareContactModal",
+ components: {
+ MaterialDesignIcon,
+ LxmfUserIcon,
+ },
+ props: {
+ show: {
+ type: Boolean,
+ default: false,
+ },
+ search: {
+ type: String,
+ default: "",
+ },
+ contacts: {
+ type: Array,
+ default: () => [],
+ },
+ resolveIcon: {
+ type: Function,
+ required: true,
+ },
+ destinationHex: {
+ type: Function,
+ required: true,
+ },
+ },
+ emits: ["close", "share", "update:search"],
+};
+</script>
diff --git a/meshchatx/src/frontend/components/messages/OutboundTransferProgressFooter.vue b/meshchatx/src/frontend/components/messages/outbound/OutboundTransferProgressFooter.vue
similarity index 100%
rename from meshchatx/src/frontend/components/messages/OutboundTransferProgressFooter.vue
rename to meshchatx/src/frontend/components/messages/outbound/OutboundTransferProgressFooter.vue
diff --git a/meshchatx/src/frontend/components/settings/SettingsPage.vue b/meshchatx/src/frontend/components/settings/SettingsPage.vue
index 6df2d46b..ebead3c0 100644
--- a/meshchatx/src/frontend/components/settings/SettingsPage.vue
+++ b/meshchatx/src/frontend/components/settings/SettingsPage.vue
@@ -725,135 +725,37 @@
@close-behavior-change="onDesktopCloseBehaviorChange"
/>
- <section
+ <AndroidSettingsSection
v-if="isMeshChatXAndroid"
- v-show="showSection('android')"
- class="settings-section break-inside-avoid"
- >
- <header class="settings-section__header">
- <div>
- <div class="settings-section__eyebrow">Android</div>
- <h2>{{ $t("settings.android_privacy_heading") }}</h2>
- <p>{{ $t("settings.android_privacy_desc") }}</p>
- </div>
- </header>
- <div class="settings-section__body space-y-4">
- <label class="setting-toggle">
- <input
- v-model="androidShellPrivacy.blockScreenshots"
- type="checkbox"
- class="rounded-sm"
- @change="saveAndroidBlockScreenshots"
- />
- <span class="setting-toggle__label">
- <span class="setting-toggle__title">{{
- $t("settings.android_block_screenshots")
- }}</span>
- <span class="setting-toggle__description">{{
- $t("settings.android_block_screenshots_desc")
- }}</span>
- </span>
- </label>
- <label class="setting-toggle">
- <input
- v-model="androidShellPrivacy.clearClipboardOnBackground"
- type="checkbox"
- class="rounded-sm"
- @change="saveAndroidClearClipboardOnBackground"
- />
- <span class="setting-toggle__label">
- <span class="setting-toggle__title">{{
- $t("settings.android_clear_clipboard_on_background")
- }}</span>
- <span class="setting-toggle__description">{{
- $t("settings.android_clear_clipboard_on_background_desc")
- }}</span>
- </span>
- </label>
- <button
- type="button"
- class="btn-maintenance border-blue-200 dark:border-blue-900/30 text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-900/10 hover:bg-blue-100 dark:hover:bg-blue-900/20"
- @click="shareAndroidApk"
- >
- <div class="flex flex-col items-start text-left">
- <div class="font-bold flex items-center gap-2">
- <MaterialDesignIcon icon-name="share-variant" class="size-4" />
- {{ $t("settings.share_apk") }}
- </div>
- <div class="text-xs opacity-80">
- {{ $t("settings.share_apk_short_hint") }}
- </div>
- </div>
- </button>
- </div>
- </section>
+ :visible="showSection('android')"
+ :android-shell-privacy="androidShellPrivacy"
+ @update:block-screenshots="
+ (v) => {
+ androidShellPrivacy.blockScreenshots = v;
+ saveAndroidBlockScreenshots();
+ }
+ "
+ @update:clear-clipboard-on-background="
+ (v) => {
+ androidShellPrivacy.clearClipboardOnBackground = v;
+ saveAndroidClearClipboardOnBackground();
+ }
+ "
+ @share-apk="shareAndroidApk"
+ />
- <!-- Page Archiver -->
- <section v-show="showSection('archiver')" class="settings-section break-inside-avoid">
- <header class="settings-section__header">
- <div>
- <div class="settings-section__eyebrow">Browsing</div>
- <h2>Page Archiver</h2>
- <p>Automatically save copies of visited NomadNetwork pages.</p>
- </div>
- </header>
- <div class="settings-section__body space-y-3">
- <label class="setting-toggle">
- <Toggle
- id="page-archiver-enabled"
- v-model="config.page_archiver_enabled"
- @update:model-value="onPageArchiverEnabledChangeWrapper"
- />
- <span class="setting-toggle__label">
- <span class="setting-toggle__title">Enable Archiver</span>
- <span class="setting-toggle__description"
- >Automatically archive pages for offline viewing and fallback.</span
- >
- </span>
- </label>
- <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
- <div class="space-y-2">
- <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
- Max Versions per Page
- </div>
- <input
- v-model.number="config.page_archiver_max_versions"
- type="number"
- min="1"
- max="50"
- class="input-field"
- @input="onPageArchiverConfigChange"
- />
- <div class="text-xs text-gray-600 dark:text-gray-400">
- How many versions of each page to keep.
- </div>
- </div>
- <div class="space-y-2">
- <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
- Max Total Storage (GB)
- </div>
- <input
- v-model.number="config.archives_max_storage_gb"
- type="number"
- min="1"
- class="input-field"
- @input="onPageArchiverConfigChange"
- />
- <div class="text-xs text-gray-600 dark:text-gray-400">
- Total storage for all archived pages.
- </div>
- </div>
- </div>
- <button
- type="button"
- class="w-full flex items-center justify-center gap-2 rounded-xl border border-red-200 dark:border-red-900/30 bg-red-50 dark:bg-red-900/20 px-4 py-2 text-sm font-semibold text-red-700 dark:text-red-300 hover:bg-red-100 dark:hover:bg-red-900/40 transition"
- @click="flushArchivedPages"
- >
- <MaterialDesignIcon icon-name="delete-sweep" class="w-4 h-4" />
- Flush All Archived Pages
- </button>
- </div>
- </section>
+ <ArchiverSettingsSection
+ :visible="showSection('archiver')"
+ :config="config"
+ @enabled-change="onPageArchiverEnabledChangeWrapper"
+ @config-change="
+ (patch) => {
+ Object.assign(config, patch);
+ onPageArchiverConfigChange();
+ }
+ "
+ @flush="flushArchivedPages"
+ />
<!-- NomadNet browser renderer -->
<section v-show="showSection('nomadRenderer')" class="settings-section break-inside-avoid">
@@ -1064,728 +966,61 @@
</section>
<!-- Appearance -->
- <section v-show="showSection('appearance')" class="settings-section break-inside-avoid">
- <header class="settings-section__header">
- <div>
- <div class="settings-section__eyebrow">Personalise</div>
- <h2>{{ $t("app.appearance") }}</h2>
- <p>{{ $t("app.appearance_description") }}</p>
- </div>
- </header>
- <div class="settings-section__body space-y-4">
- <div class="space-y-2">
- <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
- {{ $t("app.theme") }}
- </div>
- <select v-model="config.theme" class="input-field" @change="onThemeChange">
- <option value="light">{{ $t("app.light_theme") }}</option>
- <option value="dark">{{ $t("app.dark_theme") }}</option>
- </select>
- </div>
-
- <div class="space-y-2">
- <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
- {{ $t("app.messages_sidebar_position") }}
- </div>
- <select
- v-model="config.messages_sidebar_position"
- class="input-field"
- @change="onMessagesSidebarPositionChange"
- >
- <option value="left">{{ $t("app.messages_sidebar_position_left") }}</option>
- <option value="right">{{ $t("app.messages_sidebar_position_right") }}</option>
- </select>
- </div>
-
- <div class="space-y-2">
- <div class="flex items-center justify-between">
- <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
- Message Font Size
- </div>
- <div class="text-xs font-mono text-blue-500 dark:text-blue-400">
- {{ config.message_font_size || 14 }}px
- </div>
- </div>
- <div class="flex items-center gap-3">
- <span class="text-xs text-gray-400">A</span>
- <input
- v-model.number="config.message_font_size"
- type="range"
- min="10"
- max="32"
- step="1"
- class="flex-1 h-1.5 bg-gray-200 dark:bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-blue-500"
- @input="onMessageFontSizeChange"
- />
- <span class="text-lg text-gray-400">A</span>
- </div>
- </div>
-
- <div class="space-y-2">
- <div class="flex items-center justify-between">
- <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
- Icon Size
- </div>
- <div class="text-xs font-mono text-blue-500 dark:text-blue-400">
- {{ config.message_icon_size || 28 }}px
- </div>
- </div>
- <div class="flex items-center gap-3">
- <MaterialDesignIcon
- icon-name="account-outline"
- class="shrink-0 text-gray-400"
- :style="{ width: '16px', height: '16px' }"
- />
- <input
- v-model.number="config.message_icon_size"
- type="range"
- min="16"
- max="64"
- step="1"
- class="flex-1 h-1.5 bg-gray-200 dark:bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-blue-500"
- @input="onMessageIconSizeChange"
- />
- <MaterialDesignIcon
- icon-name="account"
- class="shrink-0 text-gray-500 dark:text-gray-300"
- :style="messageIconPreviewStyle"
- />
- </div>
- </div>
-
- <div class="space-y-2">
- <div class="flex items-center justify-between">
- <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
- {{ $t("app.ui_transparency") }}
- </div>
- <div class="text-xs font-mono text-blue-500 dark:text-blue-400">
- {{ Math.max(0, Math.min(100, Number(config.ui_transparency) || 0)) }}%
- </div>
- </div>
- <div class="flex items-center gap-3">
- <span class="text-xs text-gray-400">0</span>
- <input
- v-model.number="config.ui_transparency"
- type="range"
- min="0"
- max="100"
- step="1"
- class="flex-1 h-1.5 bg-gray-200 dark:bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-blue-500"
- @input="onUiTransparencyChange"
- />
- <span class="text-xs text-gray-400">100</span>
- </div>
- <div class="text-xs text-gray-600 dark:text-gray-400">
- {{ $t("app.ui_transparency_description") }}
- </div>
- </div>
-
- <label class="setting-toggle">
- <Toggle
- id="ui-glass-enabled"
- v-model="config.ui_glass_enabled"
- @update:model-value="onUiGlassEnabledChange"
- />
- <span class="setting-toggle__label">
- <span class="setting-toggle__title">{{ $t("app.ui_glass_enabled") }}</span>
- <span class="setting-toggle__description">{{
- $t("app.ui_glass_enabled_description")
- }}</span>
- </span>
- </label>
-
- <label class="setting-toggle">
- <Toggle
- id="messages-multi-pane-enabled"
- v-model="config.messages_multi_pane_enabled"
- @update:model-value="onMessagesMultiPaneEnabledChange"
- />
- <span class="setting-toggle__label">
- <span class="setting-toggle__title">{{
- $t("app.messages_multi_pane_enabled")
- }}</span>
- <span class="setting-toggle__description">{{
- $t("app.messages_multi_pane_enabled_description")
- }}</span>
- </span>
- </label>
-
- <label class="setting-toggle">
- <Toggle
- id="nomad-tabs-enabled"
- v-model="config.nomad_tabs_enabled"
- @update:model-value="onNomadTabsEnabledChange"
- />
- <span class="setting-toggle__label">
- <span class="setting-toggle__title">{{ $t("app.nomad_tabs_enabled") }}</span>
- <span class="setting-toggle__description">{{
- $t("app.nomad_tabs_enabled_description")
- }}</span>
- </span>
- </label>
-
- <label class="setting-toggle">
- <Toggle
- id="rrc-enabled"
- v-model="config.rrc_enabled"
- @update:model-value="onRrcEnabledChange"
- />
- <span class="setting-toggle__label">
- <span class="setting-toggle__title">{{ $t("app.rrc_enabled") }}</span>
- <span class="setting-toggle__description">{{
- $t("app.rrc_enabled_description")
- }}</span>
- </span>
- </label>
-
- <label v-if="config.rrc_enabled" class="setting-toggle">
- <Toggle
- id="rrc-unread-badges"
- v-model="config.rrc_unread_badges_enabled"
- @update:model-value="onRrcUnreadBadgesEnabledChange"
- />
- <span class="setting-toggle__label">
- <span class="setting-toggle__title">{{
- $t("app.rrc_unread_badges_enabled")
- }}</span>
- <span class="setting-toggle__description">{{
- $t("app.rrc_unread_badges_enabled_description")
- }}</span>
- </span>
- </label>
-
- <div class="pt-1">
- <button
- type="button"
- class="p-0 border-0 bg-transparent text-sm font-medium text-blue-600 dark:text-blue-400 hover:underline cursor-pointer"
- @click="resetAppearanceDefaults"
- >
- {{ $t("app.reset_appearance_defaults") }}
- </button>
- </div>
-
- <div class="space-y-4 pt-2">
- <div
- class="text-sm font-bold text-gray-400 dark:text-zinc-500 uppercase tracking-wider"
- >
- Message Bubbles
- </div>
-
- <div
- class="flex items-start gap-3 rounded-xl border border-gray-200 dark:border-zinc-700 px-3 py-2.5"
- >
- <input
- id="detailed-outbound-send-status"
- type="checkbox"
- class="mt-1 rounded-sm border-gray-300 dark:border-zinc-600"
- :checked="GlobalState.detailedOutboundSendStatus"
- @change="onDetailedOutboundSendStatusChange"
- />
- <label for="detailed-outbound-send-status" class="min-w-0 cursor-pointer">
- <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
- {{ $t("app.detailed_outbound_send_status") }}
- </div>
- <div class="text-xs text-gray-500 dark:text-zinc-400 mt-0.5">
- {{ $t("app.detailed_outbound_send_status_description") }}
- </div>
- </label>
- </div>
-
- <div
- class="flex items-start gap-3 rounded-xl border border-gray-200 dark:border-zinc-700 px-3 py-2.5"
- >
- <input
- id="outbound-transfer-progress-enabled"
- type="checkbox"
- class="mt-1 rounded-sm border-gray-300 dark:border-zinc-600"
- :checked="GlobalState.outboundTransferProgressEnabled"
- @change="onOutboundTransferProgressEnabledChange"
- />
- <label for="outbound-transfer-progress-enabled" class="min-w-0 cursor-pointer">
- <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
- {{ $t("app.outbound_transfer_progress_enabled") }}
- </div>
- <div class="text-xs text-gray-500 dark:text-zinc-400 mt-0.5">
- {{ $t("app.outbound_transfer_progress_enabled_description") }}
- </div>
- </label>
- </div>
-
- <div
- class="flex items-start gap-3 rounded-xl border border-gray-200 dark:border-zinc-700 px-3 py-2.5"
- >
- <input
- id="message-timestamp-grouping"
- type="checkbox"
- class="mt-1 rounded-sm border-gray-300 dark:border-zinc-600"
- :checked="GlobalState.messageTimestampGroupingEnabled"
- @change="onMessageTimestampGroupingChange"
- />
- <label for="message-timestamp-grouping" class="min-w-0 cursor-pointer">
- <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
- {{ $t("app.message_timestamp_grouping") }}
- </div>
- <div class="text-xs text-gray-500 dark:text-zinc-400 mt-0.5">
- {{ $t("app.message_timestamp_grouping_description") }}
- </div>
- </label>
- </div>
-
- <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
- <div class="space-y-2">
- <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
- Outbound Color
- </div>
- <div class="flex gap-2">
- <input
- v-model="config.message_outbound_bubble_color"
- type="color"
- class="w-12 h-10 rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 cursor-pointer"
- @input="onMessageBubbleColorChange('outbound')"
- />
- <input
- v-model="config.message_outbound_bubble_color"
- type="text"
- class="input-field monospace-field flex-1"
- @input="onMessageBubbleColorChange('outbound')"
- />
- </div>
- </div>
-
- <div class="space-y-2">
- <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
- Failed Color
- </div>
- <div class="flex gap-2">
- <input
- v-model="config.message_failed_bubble_color"
- type="color"
- class="w-12 h-10 rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 cursor-pointer"
- @input="onMessageBubbleColorChange('failed')"
- />
- <input
- v-model="config.message_failed_bubble_color"
- type="text"
- class="input-field monospace-field flex-1"
- @input="onMessageBubbleColorChange('failed')"
- />
- </div>
- </div>
-
- <div class="space-y-2">
- <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
- Waiting Color
- </div>
- <div class="flex gap-2">
- <input
- v-model="config.message_waiting_bubble_color"
- type="color"
- class="w-12 h-10 rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 cursor-pointer"
- @input="onMessageBubbleColorChange('waiting')"
- />
- <input
- v-model="config.message_waiting_bubble_color"
- type="text"
- class="input-field monospace-field flex-1"
- @input="onMessageBubbleColorChange('waiting')"
- />
- </div>
- </div>
- </div>
-
- <div class="space-y-2">
- <div class="flex items-center justify-between">
- <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
- Inbound Color (Optional)
- </div>
- <button
- v-if="config.message_inbound_bubble_color"
- type="button"
- class="text-[10px] text-red-500 font-bold uppercase hover:underline"
- @click="
- config.message_inbound_bubble_color = null;
- onMessageBubbleColorChange('inbound');
- "
- >
- Reset to default
- </button>
- </div>
- <div class="flex gap-2">
- <input
- v-if="config.message_inbound_bubble_color"
- v-model="config.message_inbound_bubble_color"
- type="color"
- class="w-12 h-10 rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 cursor-pointer"
- @input="onMessageBubbleColorChange('inbound')"
- />
- <div
- v-if="!config.message_inbound_bubble_color"
- class="flex-1 flex items-center px-3 text-xs text-gray-400 bg-gray-50 dark:bg-zinc-900 rounded-xl border border-dashed border-gray-200 dark:border-zinc-800 italic"
- >
- Using theme default. Click to customize ->
- <button
- class="ml-2 px-2 py-1 bg-blue-500 text-white rounded-lg not-italic font-bold"
- @click="
- config.message_inbound_bubble_color = '#ffffff';
- onMessageBubbleColorChange('inbound');
- "
- >
- Customize
- </button>
- </div>
- <input
- v-else
- v-model="config.message_inbound_bubble_color"
- type="text"
- class="input-field monospace-field flex-1"
- @input="onMessageBubbleColorChange('inbound')"
- />
- </div>
- </div>
- </div>
- </div>
- </section>
+ <AppearanceSettingsSection
+ :visible="showSection('appearance')"
+ :config="config"
+ :detailed-outbound-send-status="GlobalState.detailedOutboundSendStatus"
+ :outbound-transfer-progress-enabled="GlobalState.outboundTransferProgressEnabled"
+ :message-timestamp-grouping-enabled="GlobalState.messageTimestampGroupingEnabled"
+ :message-icon-preview-style="messageIconPreviewStyle"
+ @update-field="
+ (p) => {
+ config[p.key] = p.value;
+ }
+ "
+ @theme-change="onThemeChange"
+ @messages-sidebar-position-change="onMessagesSidebarPositionChange"
+ @message-font-size-change="onMessageFontSizeChange"
+ @message-icon-size-change="onMessageIconSizeChange"
+ @ui-transparency-change="onUiTransparencyChange"
+ @ui-glass-enabled-change="onUiGlassEnabledChange"
+ @messages-multi-pane-enabled-change="onMessagesMultiPaneEnabledChange"
+ @nomad-tabs-enabled-change="onNomadTabsEnabledChange"
+ @rrc-enabled-change="onRrcEnabledChange"
+ @rrc-unread-badges-enabled-change="onRrcUnreadBadgesEnabledChange"
+ @reset-appearance-defaults="resetAppearanceDefaults"
+ @detailed-outbound-send-status-change="onDetailedOutboundSendStatusChange"
+ @outbound-transfer-progress-enabled-change="onOutboundTransferProgressEnabledChange"
+ @message-timestamp-grouping-change="onMessageTimestampGroupingChange"
+ @bubble-color-change="onMessageBubbleColorChange"
+ />
<!-- Battery saver -->
- <section v-show="showSection('battery')" class="settings-section break-inside-avoid">
- <header class="settings-section__header">
- <div>
- <div class="settings-section__eyebrow">{{ $t("settings.battery.eyebrow") }}</div>
- <h2>{{ $t("settings.battery.title") }}</h2>
- <p>{{ $t("settings.battery.description") }}</p>
- </div>
- </header>
- <div class="settings-section__body space-y-4">
- <label class="setting-toggle">
- <Toggle
- id="settings-battery-saver-enabled"
- v-model="batterySaver.enabled"
- @update:model-value="onBatterySaverEnabledChange"
- />
- <span class="setting-toggle__label">
- <span class="setting-toggle__title">{{ $t("settings.battery.enabled") }}</span>
- <span class="setting-toggle__description">{{
- $t("settings.battery.enabled_desc")
- }}</span>
- </span>
- </label>
-
- <div class="text-sm font-medium text-gray-900 dark:text-gray-100 pt-2">
- {{ $t("settings.battery.options_heading") }}
- </div>
-
- <label class="setting-toggle">
- <Toggle
- id="settings-battery-viz-discovery"
- v-model="batterySaver.disableVisualiserDiscovery"
- @update:model-value="
- (v) => patchBatterySaver({ disableVisualiserDiscovery: v })
- "
- />
- <span class="setting-toggle__label">
- <span class="setting-toggle__title">{{
- $t("settings.battery.disable_visualiser_discovery")
- }}</span>
- <span class="setting-toggle__description">{{
- $t("settings.battery.disable_visualiser_discovery_desc")
- }}</span>
- </span>
- </label>
-
- <label class="setting-toggle">
- <Toggle
- id="settings-battery-hide-offline"
- v-model="batterySaver.hideOfflineInterfaces"
- @update:model-value="(v) => patchBatterySaver({ hideOfflineInterfaces: v })"
- />
- <span class="setting-toggle__label">
- <span class="setting-toggle__title">{{
- $t("settings.battery.hide_offline_interfaces")
- }}</span>
- <span class="setting-toggle__description">{{
- $t("settings.battery.hide_offline_interfaces_desc")
- }}</span>
- </span>
- </label>
-
- <div class="space-y-2">
- <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
- {{ $t("settings.battery.max_visualiser_interfaces") }}
- </div>
- <p class="text-xs text-gray-500 dark:text-zinc-400">
- {{ $t("settings.battery.max_visualiser_interfaces_desc") }}
- </p>
- <input
- v-model.number="batterySaver.maxVisualiserInterfaces"
- type="number"
- min="0"
- max="128"
- class="input-field"
- @change="
- patchBatterySaver({
- maxVisualiserInterfaces: batterySaver.maxVisualiserInterfaces,
- })
- "
- />
- </div>
-
- <div class="space-y-2">
- <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
- {{ $t("settings.battery.visualiser_reload_seconds") }}
- </div>
- <p class="text-xs text-gray-500 dark:text-zinc-400">
- {{ $t("settings.battery.visualiser_reload_seconds_desc") }}
- </p>
- <input
- v-model.number="batterySaver.visualiserReloadSeconds"
- type="number"
- min="0"
- max="600"
- class="input-field"
- @change="
- patchBatterySaver({
- visualiserReloadSeconds: batterySaver.visualiserReloadSeconds,
- })
- "
- />
- </div>
-
- <label class="setting-toggle">
- <Toggle
- id="settings-battery-live-layout"
- v-model="batterySaver.disableVisualiserLiveLayout"
- @update:model-value="
- (v) => patchBatterySaver({ disableVisualiserLiveLayout: v })
- "
- />
- <span class="setting-toggle__label">
- <span class="setting-toggle__title">{{
- $t("settings.battery.disable_visualiser_live_layout")
- }}</span>
- <span class="setting-toggle__description">{{
- $t("settings.battery.disable_visualiser_live_layout_desc")
- }}</span>
- </span>
- </label>
-
- <label class="setting-toggle">
- <Toggle
- id="settings-battery-bg-poll"
- v-model="batterySaver.reduceBackgroundPolling"
- @update:model-value="(v) => patchBatterySaver({ reduceBackgroundPolling: v })"
- />
- <span class="setting-toggle__label">
- <span class="setting-toggle__title">{{
- $t("settings.battery.reduce_background_polling")
- }}</span>
- <span class="setting-toggle__description">{{
- $t("settings.battery.reduce_background_polling_desc")
- }}</span>
- </span>
- </label>
-
- <div class="space-y-2">
- <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
- {{ $t("settings.battery.background_poll_multiplier") }}
- </div>
- <p class="text-xs text-gray-500 dark:text-zinc-400">
- {{ $t("settings.battery.background_poll_multiplier_desc") }}
- </p>
- <input
- v-model.number="batterySaver.backgroundPollMultiplier"
- type="number"
- min="2"
- max="10"
- class="input-field"
- @change="
- patchBatterySaver({
- backgroundPollMultiplier: batterySaver.backgroundPollMultiplier,
- })
- "
- />
- </div>
-
- <label class="setting-toggle">
- <Toggle
- id="settings-battery-ifaces-discovery"
- v-model="batterySaver.reduceInterfacesDiscovery"
- @update:model-value="(v) => patchBatterySaver({ reduceInterfacesDiscovery: v })"
- />
- <span class="setting-toggle__label">
- <span class="setting-toggle__title">{{
- $t("settings.battery.reduce_interfaces_discovery")
- }}</span>
- <span class="setting-toggle__description">{{
- $t("settings.battery.reduce_interfaces_discovery_desc")
- }}</span>
- </span>
- </label>
-
- <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
- <div class="space-y-2">
- <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
- {{ $t("settings.battery.interfaces_stats_poll_seconds") }}
- </div>
- <input
- v-model.number="batterySaver.interfacesStatsPollSeconds"
- type="number"
- min="1"
- max="120"
- class="input-field"
- @change="
- patchBatterySaver({
- interfacesStatsPollSeconds: batterySaver.interfacesStatsPollSeconds,
- })
- "
- />
- </div>
- <div class="space-y-2">
- <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
- {{ $t("settings.battery.interfaces_discovery_poll_seconds") }}
- </div>
- <input
- v-model.number="batterySaver.interfacesDiscoveryPollSeconds"
- type="number"
- min="5"
- max="300"
- class="input-field"
- @change="
- patchBatterySaver({
- interfacesDiscoveryPollSeconds:
- batterySaver.interfacesDiscoveryPollSeconds,
- })
- "
- />
- </div>
- </div>
-
- <label class="setting-toggle">
- <Toggle
- id="settings-battery-bitrate-limits"
- v-model="batterySaver.applyInterfaceBitrateLimits"
- @update:model-value="
- (v) => patchBatterySaver({ applyInterfaceBitrateLimits: v === true })
- "
- />
- <span class="setting-toggle__label">
- <span class="setting-toggle__title">{{
- $t("settings.battery.apply_interface_bitrate_limits")
- }}</span>
- <span class="setting-toggle__description">{{
- $t("settings.battery.apply_interface_bitrate_limits_desc")
- }}</span>
- </span>
- </label>
-
- <div v-if="batterySaver.applyInterfaceBitrateLimits" class="space-y-3">
- <p class="text-xs text-gray-500 dark:text-zinc-400">
- {{ $t("settings.battery.interface_bitrate_limits_help") }}
- </p>
- <div
- v-if="batteryInterfaceRows.length === 0"
- class="text-xs text-gray-500 dark:text-zinc-400"
- >
- {{ $t("settings.battery.interface_bitrate_limits_empty") }}
- </div>
- <div
- v-for="row in batteryInterfaceRows"
- :key="row.name"
- class="grid grid-cols-1 sm:grid-cols-[1fr_10rem] gap-2 items-center"
- >
- <div
- class="text-sm text-gray-900 dark:text-gray-100 truncate"
- :title="row.name"
- >
- {{ row.name }}
- <span class="text-xs text-gray-500 dark:text-zinc-400">
- ({{ row.type || "?" }})
- </span>
- </div>
- <input
- v-model.number="batterySaver.interfaceBitrateLimits[row.name]"
- type="number"
- min="0"
- class="input-field"
- :placeholder="$t('settings.battery.interface_bitrate_placeholder')"
- @change="onBatteryBitrateLimitChange(row.name)"
- />
- </div>
- <div class="flex flex-wrap gap-2">
- <button
- type="button"
- class="secondary-button text-sm"
- :disabled="batteryBitrateBusy"
- @click="applyBatteryBitrateLimitsNow"
- >
- {{ $t("settings.battery.apply_bitrates_reload") }}
- </button>
- <button
- type="button"
- class="secondary-button text-sm"
- :disabled="batteryBitrateBusy"
- @click="restoreBatteryBitrateLimitsNow"
- >
- {{ $t("settings.battery.restore_bitrates_reload") }}
- </button>
- </div>
- </div>
- </div>
- </section>
+ <BatterySettingsSection
+ :visible="showSection('battery')"
+ :battery-saver="batterySaver"
+ :battery-interface-rows="batteryInterfaceRows"
+ :battery-bitrate-busy="batteryBitrateBusy"
+ @enabled-change="onBatterySaverEnabledChange"
+ @patch="patchBatterySaver"
+ @apply-bitrates="applyBatteryBitrateLimitsNow"
+ @restore-bitrates="restoreBatteryBitrateLimitsNow"
+ />
- <!-- Network Visualiser -->
- <section v-show="showSection('visualiser')" class="settings-section break-inside-avoid">
- <header class="settings-section__header">
- <div>
- <div class="settings-section__eyebrow">Visualiser</div>
- <h2>{{ $t("visualiser.title") }}</h2>
- <p>{{ $t("visualiser.description") }}</p>
- </div>
- </header>
- <div class="settings-section__body space-y-4">
- <div class="space-y-2">
- <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
- {{ $t("visualiser.renderer_title") }}
- </div>
- <p class="text-xs text-gray-600 dark:text-gray-400">
- {{ $t("visualiser.renderer_desc") }}
- </p>
- <select
- id="settings-visualiser-renderer"
- v-model="visualiserRenderer"
- class="input-field"
- @change="onVisualiserRendererChange"
- >
- <option value="auto">{{ $t("visualiser.renderer_option_auto") }}</option>
- <option value="webgl">{{ $t("visualiser.renderer_option_webgl") }}</option>
- <option value="vis">{{ $t("visualiser.renderer_option_vis") }}</option>
- </select>
- </div>
- <label class="setting-toggle">
- <Toggle
- id="settings-visualiser-offline"
- v-model="visualiserShowDisabledInterfaces"
- @update:model-value="onVisualiserShowDisabledChange"
- />
- <span class="setting-toggle__label">
- <span class="setting-toggle__title">{{
- $t("visualiser.show_disabled_interfaces")
- }}</span>
- </span>
- </label>
- <label class="setting-toggle">
- <Toggle
- id="settings-visualiser-discovered"
- v-model="visualiserShowDiscoveredInterfaces"
- @update:model-value="onVisualiserShowDiscoveredChange"
- />
- <span class="setting-toggle__label">
- <span class="setting-toggle__title">{{
- $t("visualiser.show_discovered_interfaces")
- }}</span>
- </span>
- </label>
- </div>
- </section>
+ <VisualiserSettingsSection
+ :visible="showSection('visualiser')"
+ :renderer="visualiserRenderer"
+ :show-disabled-interfaces="visualiserShowDisabledInterfaces"
+ :show-discovered-interfaces="visualiserShowDiscoveredInterfaces"
+ @renderer-change="
+ (v) => {
+ visualiserRenderer = v;
+ onVisualiserRendererChange();
+ }
+ "
+ @show-disabled-change="onVisualiserShowDisabledChange"
+ @show-discovered-change="onVisualiserShowDiscoveredChange"
+ />
<!-- Location (map & coordinates) -->
<section v-show="showSection('location')" class="settings-section break-inside-avoid">
@@ -2522,25 +1757,7 @@
</div>
</section>
- <!-- Blocked -->
- <section v-show="showSection('blocked')" class="settings-section break-inside-avoid">
- <header class="settings-section__header">
- <div>
- <div class="settings-section__eyebrow">Privacy</div>
- <h2>Banished</h2>
- <p>Manage Banished users and nodes</p>
- </div>
- <RouterLink :to="{ name: 'blocked' }" class="primary-chip">
- Manage Banished
- </RouterLink>
- </header>
- <div class="settings-section__body">
- <p class="text-sm text-gray-600 dark:text-gray-400">
- Banished users and nodes will not be able to send you messages, and their announces
- will be ignored.
- </p>
- </div>
- </section>
+ <BlockedSettingsSection :visible="showSection('blocked')" />
<SettingsSectionBlock
v-show="showSection('privacyData')"
@@ -3569,6 +2786,12 @@ import BanishmentSettingsSection from "./sections/BanishmentSettingsSection.vue"
import StickersSettingsSection from "./sections/StickersSettingsSection.vue";
import GifsSettingsSection from "./sections/GifsSettingsSection.vue";
import TelephonySettingsSection from "./sections/TelephonySettingsSection.vue";
+import AppearanceSettingsSection from "./sections/AppearanceSettingsSection.vue";
+import BatterySettingsSection from "./sections/BatterySettingsSection.vue";
+import VisualiserSettingsSection from "./sections/VisualiserSettingsSection.vue";
+import BlockedSettingsSection from "./sections/BlockedSettingsSection.vue";
+import AndroidSettingsSection from "./sections/AndroidSettingsSection.vue";
+import ArchiverSettingsSection from "./sections/ArchiverSettingsSection.vue";
import SettingsNav from "./SettingsNav.vue";
import KeyboardShortcuts from "../../js/KeyboardShortcuts";
import ElectronUtils from "../../js/ElectronUtils";
@@ -3633,6 +2856,12 @@ export default {
StickersSettingsSection,
GifsSettingsSection,
TelephonySettingsSection,
+ AppearanceSettingsSection,
+ BatterySettingsSection,
+ VisualiserSettingsSection,
+ BlockedSettingsSection,
+ AndroidSettingsSection,
+ ArchiverSettingsSection,
MicronWasmUpdateModal,
NotificationSoundSettings,
},
diff --git a/meshchatx/src/frontend/components/settings/sections/AndroidSettingsSection.vue b/meshchatx/src/frontend/components/settings/sections/AndroidSettingsSection.vue
new file mode 100644
index 00000000..4097675c
--- /dev/null
+++ b/meshchatx/src/frontend/components/settings/sections/AndroidSettingsSection.vue
@@ -0,0 +1,80 @@
+<!-- SPDX-License-Identifier: 0BSD -->
+
+<template>
+ <section v-show="visible" class="settings-section break-inside-avoid">
+ <header class="settings-section__header">
+ <div>
+ <div class="settings-section__eyebrow">Android</div>
+ <h2>{{ $t("settings.android_privacy_heading") }}</h2>
+ <p>{{ $t("settings.android_privacy_desc") }}</p>
+ </div>
+ </header>
+ <div class="settings-section__body space-y-4">
+ <label class="setting-toggle">
+ <input
+ :checked="androidShellPrivacy.blockScreenshots"
+ type="checkbox"
+ class="rounded-sm"
+ @change="$emit('update:blockScreenshots', $event.target.checked)"
+ />
+ <span class="setting-toggle__label">
+ <span class="setting-toggle__title">{{ $t("settings.android_block_screenshots") }}</span>
+ <span class="setting-toggle__description">{{ $t("settings.android_block_screenshots_desc") }}</span>
+ </span>
+ </label>
+ <label class="setting-toggle">
+ <input
+ :checked="androidShellPrivacy.clearClipboardOnBackground"
+ type="checkbox"
+ class="rounded-sm"
+ @change="$emit('update:clearClipboardOnBackground', $event.target.checked)"
+ />
+ <span class="setting-toggle__label">
+ <span class="setting-toggle__title">{{
+ $t("settings.android_clear_clipboard_on_background")
+ }}</span>
+ <span class="setting-toggle__description">{{
+ $t("settings.android_clear_clipboard_on_background_desc")
+ }}</span>
+ </span>
+ </label>
+ <button
+ type="button"
+ class="btn-maintenance border-blue-200 dark:border-blue-900/30 text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-900/10 hover:bg-blue-100 dark:hover:bg-blue-900/20"
+ @click="$emit('share-apk')"
+ >
+ <div class="flex flex-col items-start text-left">
+ <div class="font-bold flex items-center gap-2">
+ <MaterialDesignIcon icon-name="share-variant" class="size-4" />
+ {{ $t("settings.share_apk") }}
+ </div>
+ <div class="text-xs opacity-80">
+ {{ $t("settings.share_apk_short_hint") }}
+ </div>
+ </div>
+ </button>
+ </div>
+ </section>
+</template>
+
+<script>
+import MaterialDesignIcon from "../../MaterialDesignIcon.vue";
+
+export default {
+ name: "AndroidSettingsSection",
+ components: {
+ MaterialDesignIcon,
+ },
+ props: {
+ visible: {
+ type: Boolean,
+ default: true,
+ },
+ androidShellPrivacy: {
+ type: Object,
+ required: true,
+ },
+ },
+ emits: ["update:blockScreenshots", "update:clearClipboardOnBackground", "share-apk"],
+};
+</script>
diff --git a/meshchatx/src/frontend/components/settings/sections/AppearanceSettingsSection.vue b/meshchatx/src/frontend/components/settings/sections/AppearanceSettingsSection.vue
new file mode 100644
index 00000000..c1097e33
--- /dev/null
+++ b/meshchatx/src/frontend/components/settings/sections/AppearanceSettingsSection.vue
@@ -0,0 +1,453 @@
+<!-- SPDX-License-Identifier: 0BSD -->
+
+<template>
+ <section v-show="visible" class="settings-section break-inside-avoid">
+ <header class="settings-section__header">
+ <div>
+ <div class="settings-section__eyebrow">Personalise</div>
+ <h2>{{ $t("app.appearance") }}</h2>
+ <p>{{ $t("app.appearance_description") }}</p>
+ </div>
+ </header>
+ <div class="settings-section__body space-y-4">
+ <div class="space-y-2">
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
+ {{ $t("app.theme") }}
+ </div>
+ <select :value="config.theme" class="input-field" @change="onThemeSelect">
+ <option value="light">{{ $t("app.light_theme") }}</option>
+ <option value="dark">{{ $t("app.dark_theme") }}</option>
+ </select>
+ </div>
+
+ <div class="space-y-2">
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
+ {{ $t("app.messages_sidebar_position") }}
+ </div>
+ <select
+ :value="config.messages_sidebar_position"
+ class="input-field"
+ @change="onMessagesSidebarPositionSelect"
+ >
+ <option value="left">{{ $t("app.messages_sidebar_position_left") }}</option>
+ <option value="right">{{ $t("app.messages_sidebar_position_right") }}</option>
+ </select>
+ </div>
+
+ <div class="space-y-2">
+ <div class="flex items-center justify-between">
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100">Message Font Size</div>
+ <div class="text-xs font-mono text-blue-500 dark:text-blue-400">
+ {{ config.message_font_size || 14 }}px
+ </div>
+ </div>
+ <div class="flex items-center gap-3">
+ <span class="text-xs text-gray-400">A</span>
+ <input
+ :value="config.message_font_size"
+ type="range"
+ min="10"
+ max="32"
+ step="1"
+ class="flex-1 h-1.5 bg-gray-200 dark:bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-blue-500"
+ @input="onMessageFontSizeInput"
+ />
+ <span class="text-lg text-gray-400">A</span>
+ </div>
+ </div>
+
+ <div class="space-y-2">
+ <div class="flex items-center justify-between">
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100">Icon Size</div>
+ <div class="text-xs font-mono text-blue-500 dark:text-blue-400">
+ {{ config.message_icon_size || 28 }}px
+ </div>
+ </div>
+ <div class="flex items-center gap-3">
+ <MaterialDesignIcon
+ icon-name="account-outline"
+ class="shrink-0 text-gray-400"
+ :style="{ width: '16px', height: '16px' }"
+ />
+ <input
+ :value="config.message_icon_size"
+ type="range"
+ min="16"
+ max="64"
+ step="1"
+ class="flex-1 h-1.5 bg-gray-200 dark:bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-blue-500"
+ @input="onMessageIconSizeInput"
+ />
+ <MaterialDesignIcon
+ icon-name="account"
+ class="shrink-0 text-gray-500 dark:text-gray-300"
+ :style="messageIconPreviewStyle"
+ />
+ </div>
+ </div>
+
+ <div class="space-y-2">
+ <div class="flex items-center justify-between">
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
+ {{ $t("app.ui_transparency") }}
+ </div>
+ <div class="text-xs font-mono text-blue-500 dark:text-blue-400">
+ {{ Math.max(0, Math.min(100, Number(config.ui_transparency) || 0)) }}%
+ </div>
+ </div>
+ <div class="flex items-center gap-3">
+ <span class="text-xs text-gray-400">0</span>
+ <input
+ :value="config.ui_transparency"
+ type="range"
+ min="0"
+ max="100"
+ step="1"
+ class="flex-1 h-1.5 bg-gray-200 dark:bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-blue-500"
+ @input="onUiTransparencyInput"
+ />
+ <span class="text-xs text-gray-400">100</span>
+ </div>
+ <div class="text-xs text-gray-600 dark:text-gray-400">
+ {{ $t("app.ui_transparency_description") }}
+ </div>
+ </div>
+
+ <label class="setting-toggle">
+ <Toggle
+ id="ui-glass-enabled"
+ :model-value="config.ui_glass_enabled"
+ @update:model-value="onUiGlassEnabledToggle"
+ />
+ <span class="setting-toggle__label">
+ <span class="setting-toggle__title">{{ $t("app.ui_glass_enabled") }}</span>
+ <span class="setting-toggle__description">{{ $t("app.ui_glass_enabled_description") }}</span>
+ </span>
+ </label>
+
+ <label class="setting-toggle">
+ <Toggle
+ id="messages-multi-pane-enabled"
+ :model-value="config.messages_multi_pane_enabled"
+ @update:model-value="onMessagesMultiPaneEnabledToggle"
+ />
+ <span class="setting-toggle__label">
+ <span class="setting-toggle__title">{{ $t("app.messages_multi_pane_enabled") }}</span>
+ <span class="setting-toggle__description">{{
+ $t("app.messages_multi_pane_enabled_description")
+ }}</span>
+ </span>
+ </label>
+
+ <label class="setting-toggle">
+ <Toggle
+ id="nomad-tabs-enabled"
+ :model-value="config.nomad_tabs_enabled"
+ @update:model-value="onNomadTabsEnabledToggle"
+ />
+ <span class="setting-toggle__label">
+ <span class="setting-toggle__title">{{ $t("app.nomad_tabs_enabled") }}</span>
+ <span class="setting-toggle__description">{{ $t("app.nomad_tabs_enabled_description") }}</span>
+ </span>
+ </label>
+
+ <label class="setting-toggle">
+ <Toggle id="rrc-enabled" :model-value="config.rrc_enabled" @update:model-value="onRrcEnabledToggle" />
+ <span class="setting-toggle__label">
+ <span class="setting-toggle__title">{{ $t("app.rrc_enabled") }}</span>
+ <span class="setting-toggle__description">{{ $t("app.rrc_enabled_description") }}</span>
+ </span>
+ </label>
+
+ <label v-if="config.rrc_enabled" class="setting-toggle">
+ <Toggle
+ id="rrc-unread-badges"
+ :model-value="config.rrc_unread_badges_enabled"
+ @update:model-value="onRrcUnreadBadgesEnabledToggle"
+ />
+ <span class="setting-toggle__label">
+ <span class="setting-toggle__title">{{ $t("app.rrc_unread_badges_enabled") }}</span>
+ <span class="setting-toggle__description">{{
+ $t("app.rrc_unread_badges_enabled_description")
+ }}</span>
+ </span>
+ </label>
+
+ <div class="pt-1">
+ <button
+ type="button"
+ class="p-0 border-0 bg-transparent text-sm font-medium text-blue-600 dark:text-blue-400 hover:underline cursor-pointer"
+ @click="$emit('reset-appearance-defaults')"
+ >
+ {{ $t("app.reset_appearance_defaults") }}
+ </button>
+ </div>
+
+ <div class="space-y-4 pt-2">
+ <div class="text-sm font-bold text-gray-400 dark:text-zinc-500 uppercase tracking-wider">
+ Message Bubbles
+ </div>
+
+ <div class="flex items-start gap-3 rounded-xl border border-gray-200 dark:border-zinc-700 px-3 py-2.5">
+ <input
+ id="detailed-outbound-send-status"
+ type="checkbox"
+ class="mt-1 rounded-sm border-gray-300 dark:border-zinc-600"
+ :checked="detailedOutboundSendStatus"
+ @change="$emit('detailed-outbound-send-status-change', $event)"
+ />
+ <label for="detailed-outbound-send-status" class="min-w-0 cursor-pointer">
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
+ {{ $t("app.detailed_outbound_send_status") }}
+ </div>
+ <div class="text-xs text-gray-500 dark:text-zinc-400 mt-0.5">
+ {{ $t("app.detailed_outbound_send_status_description") }}
+ </div>
+ </label>
+ </div>
+
+ <div class="flex items-start gap-3 rounded-xl border border-gray-200 dark:border-zinc-700 px-3 py-2.5">
+ <input
+ id="outbound-transfer-progress-enabled"
+ type="checkbox"
+ class="mt-1 rounded-sm border-gray-300 dark:border-zinc-600"
+ :checked="outboundTransferProgressEnabled"
+ @change="$emit('outbound-transfer-progress-enabled-change', $event)"
+ />
+ <label for="outbound-transfer-progress-enabled" class="min-w-0 cursor-pointer">
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
+ {{ $t("app.outbound_transfer_progress_enabled") }}
+ </div>
+ <div class="text-xs text-gray-500 dark:text-zinc-400 mt-0.5">
+ {{ $t("app.outbound_transfer_progress_enabled_description") }}
+ </div>
+ </label>
+ </div>
+
+ <div class="flex items-start gap-3 rounded-xl border border-gray-200 dark:border-zinc-700 px-3 py-2.5">
+ <input
+ id="message-timestamp-grouping"
+ type="checkbox"
+ class="mt-1 rounded-sm border-gray-300 dark:border-zinc-600"
+ :checked="messageTimestampGroupingEnabled"
+ @change="$emit('message-timestamp-grouping-change', $event)"
+ />
+ <label for="message-timestamp-grouping" class="min-w-0 cursor-pointer">
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
+ {{ $t("app.message_timestamp_grouping") }}
+ </div>
+ <div class="text-xs text-gray-500 dark:text-zinc-400 mt-0.5">
+ {{ $t("app.message_timestamp_grouping_description") }}
+ </div>
+ </label>
+ </div>
+
+ <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
+ <div class="space-y-2">
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100">Outbound Color</div>
+ <div class="flex gap-2">
+ <input
+ :value="config.message_outbound_bubble_color"
+ type="color"
+ class="w-12 h-10 rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 cursor-pointer"
+ @input="onBubbleColorInput('outbound', $event)"
+ />
+ <input
+ :value="config.message_outbound_bubble_color"
+ type="text"
+ class="input-field monospace-field flex-1"
+ @input="onBubbleColorInput('outbound', $event)"
+ />
+ </div>
+ </div>
+
+ <div class="space-y-2">
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100">Failed Color</div>
+ <div class="flex gap-2">
+ <input
+ :value="config.message_failed_bubble_color"
+ type="color"
+ class="w-12 h-10 rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 cursor-pointer"
+ @input="onBubbleColorInput('failed', $event)"
+ />
+ <input
+ :value="config.message_failed_bubble_color"
+ type="text"
+ class="input-field monospace-field flex-1"
+ @input="onBubbleColorInput('failed', $event)"
+ />
+ </div>
+ </div>
+
+ <div class="space-y-2">
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100">Waiting Color</div>
+ <div class="flex gap-2">
+ <input
+ :value="config.message_waiting_bubble_color"
+ type="color"
+ class="w-12 h-10 rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 cursor-pointer"
+ @input="onBubbleColorInput('waiting', $event)"
+ />
+ <input
+ :value="config.message_waiting_bubble_color"
+ type="text"
+ class="input-field monospace-field flex-1"
+ @input="onBubbleColorInput('waiting', $event)"
+ />
+ </div>
+ </div>
+ </div>
+
+ <div class="space-y-2">
+ <div class="flex items-center justify-between">
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100">Inbound Color (Optional)</div>
+ <button
+ v-if="config.message_inbound_bubble_color"
+ type="button"
+ class="text-[10px] text-red-500 font-bold uppercase hover:underline"
+ @click="onInboundBubbleReset"
+ >
+ Reset to default
+ </button>
+ </div>
+ <div class="flex gap-2">
+ <input
+ v-if="config.message_inbound_bubble_color"
+ :value="config.message_inbound_bubble_color"
+ type="color"
+ class="w-12 h-10 rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 cursor-pointer"
+ @input="onBubbleColorInput('inbound', $event)"
+ />
+ <div
+ v-if="!config.message_inbound_bubble_color"
+ class="flex-1 flex items-center px-3 text-xs text-gray-400 bg-gray-50 dark:bg-zinc-900 rounded-xl border border-dashed border-gray-200 dark:border-zinc-800 italic"
+ >
+ Using theme default. Click to customize ->
+ <button
+ class="ml-2 px-2 py-1 bg-blue-500 text-white rounded-lg not-italic font-bold"
+ @click="onInboundBubbleCustomize"
+ >
+ Customize
+ </button>
+ </div>
+ <input
+ v-else
+ :value="config.message_inbound_bubble_color"
+ type="text"
+ class="input-field monospace-field flex-1"
+ @input="onBubbleColorInput('inbound', $event)"
+ />
+ </div>
+ </div>
+ </div>
+ </div>
+ </section>
+</template>
+
+<script>
+import Toggle from "../../forms/Toggle.vue";
+import MaterialDesignIcon from "../../MaterialDesignIcon.vue";
+
+export default {
+ name: "AppearanceSettingsSection",
+ components: {
+ Toggle,
+ MaterialDesignIcon,
+ },
+ props: {
+ visible: {
+ type: Boolean,
+ default: true,
+ },
+ config: {
+ type: Object,
+ required: true,
+ },
+ detailedOutboundSendStatus: {
+ type: Boolean,
+ default: false,
+ },
+ outboundTransferProgressEnabled: {
+ type: Boolean,
+ default: false,
+ },
+ messageTimestampGroupingEnabled: {
+ type: Boolean,
+ default: false,
+ },
+ messageIconPreviewStyle: {
+ type: Object,
+ default: () => ({}),
+ },
+ },
+ emits: [
+ "update-field",
+ "theme-change",
+ "messages-sidebar-position-change",
+ "message-font-size-change",
+ "message-icon-size-change",
+ "ui-transparency-change",
+ "ui-glass-enabled-change",
+ "messages-multi-pane-enabled-change",
+ "nomad-tabs-enabled-change",
+ "rrc-enabled-change",
+ "rrc-unread-badges-enabled-change",
+ "reset-appearance-defaults",
+ "detailed-outbound-send-status-change",
+ "outbound-transfer-progress-enabled-change",
+ "message-timestamp-grouping-change",
+ "bubble-color-change",
+ ],
+ methods: {
+ emitField(key, value, eventName, eventArg) {
+ this.$emit("update-field", { key, value });
+ if (eventName) {
+ if (eventArg !== undefined) {
+ this.$emit(eventName, eventArg);
+ } else {
+ this.$emit(eventName);
+ }
+ }
+ },
+ onThemeSelect(event) {
+ this.emitField("theme", event.target.value, "theme-change");
+ },
+ onMessagesSidebarPositionSelect(event) {
+ this.emitField("messages_sidebar_position", event.target.value, "messages-sidebar-position-change");
+ },
+ onMessageFontSizeInput(event) {
+ this.emitField("message_font_size", Number(event.target.value), "message-font-size-change");
+ },
+ onMessageIconSizeInput(event) {
+ this.emitField("message_icon_size", Number(event.target.value), "message-icon-size-change");
+ },
+ onUiTransparencyInput(event) {
+ this.emitField("ui_transparency", Number(event.target.value), "ui-transparency-change");
+ },
+ onUiGlassEnabledToggle(value) {
+ this.emitField("ui_glass_enabled", value, "ui-glass-enabled-change");
+ },
+ onMessagesMultiPaneEnabledToggle(value) {
+ this.emitField("messages_multi_pane_enabled", value, "messages-multi-pane-enabled-change");
+ },
+ onNomadTabsEnabledToggle(value) {
+ this.emitField("nomad_tabs_enabled", value, "nomad-tabs-enabled-change");
+ },
+ onRrcEnabledToggle(value) {
+ this.emitField("rrc_enabled", value, "rrc-enabled-change");
+ },
+ onRrcUnreadBadgesEnabledToggle(value) {
+ this.emitField("rrc_unread_badges_enabled", value, "rrc-unread-badges-enabled-change");
+ },
+ onBubbleColorInput(type, event) {
+ this.emitField(`message_${type}_bubble_color`, event.target.value, "bubble-color-change", type);
+ },
+ onInboundBubbleReset() {
+ this.emitField("message_inbound_bubble_color", null, "bubble-color-change", "inbound");
+ },
+ onInboundBubbleCustomize() {
+ this.emitField("message_inbound_bubble_color", "#ffffff", "bubble-color-change", "inbound");
+ },
+ },
+};
+</script>
diff --git a/meshchatx/src/frontend/components/settings/sections/ArchiverSettingsSection.vue b/meshchatx/src/frontend/components/settings/sections/ArchiverSettingsSection.vue
new file mode 100644
index 00000000..04ca1641
--- /dev/null
+++ b/meshchatx/src/frontend/components/settings/sections/ArchiverSettingsSection.vue
@@ -0,0 +1,93 @@
+<!-- SPDX-License-Identifier: 0BSD -->
+
+<template>
+ <section v-show="visible" class="settings-section break-inside-avoid">
+ <header class="settings-section__header">
+ <div>
+ <div class="settings-section__eyebrow">Browsing</div>
+ <h2>Page Archiver</h2>
+ <p>Automatically save copies of visited NomadNetwork pages.</p>
+ </div>
+ </header>
+ <div class="settings-section__body space-y-3">
+ <label class="setting-toggle">
+ <Toggle
+ id="page-archiver-enabled"
+ :model-value="config.page_archiver_enabled"
+ @update:model-value="$emit('enabled-change', $event)"
+ />
+ <span class="setting-toggle__label">
+ <span class="setting-toggle__title">Enable Archiver</span>
+ <span class="setting-toggle__description"
+ >Automatically archive pages for offline viewing and fallback.</span
+ >
+ </span>
+ </label>
+ <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
+ <div class="space-y-2">
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100">Max Versions per Page</div>
+ <input
+ :value="config.page_archiver_max_versions"
+ type="number"
+ min="1"
+ max="50"
+ class="input-field"
+ @input="
+ $emit('config-change', {
+ page_archiver_max_versions: Number($event.target.value),
+ })
+ "
+ />
+ <div class="text-xs text-gray-600 dark:text-gray-400">How many versions of each page to keep.</div>
+ </div>
+ <div class="space-y-2">
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100">Max Total Storage (GB)</div>
+ <input
+ :value="config.archives_max_storage_gb"
+ type="number"
+ min="1"
+ class="input-field"
+ @input="
+ $emit('config-change', {
+ archives_max_storage_gb: Number($event.target.value),
+ })
+ "
+ />
+ <div class="text-xs text-gray-600 dark:text-gray-400">Total storage for all archived pages.</div>
+ </div>
+ </div>
+ <button
+ type="button"
+ class="w-full flex items-center justify-center gap-2 rounded-xl border border-red-200 dark:border-red-900/30 bg-red-50 dark:bg-red-900/20 px-4 py-2 text-sm font-semibold text-red-700 dark:text-red-300 hover:bg-red-100 dark:hover:bg-red-900/40 transition"
+ @click="$emit('flush')"
+ >
+ <MaterialDesignIcon icon-name="delete-sweep" class="w-4 h-4" />
+ Flush All Archived Pages
+ </button>
+ </div>
+ </section>
+</template>
+
+<script>
+import Toggle from "../../forms/Toggle.vue";
+import MaterialDesignIcon from "../../MaterialDesignIcon.vue";
+
+export default {
+ name: "ArchiverSettingsSection",
+ components: {
+ Toggle,
+ MaterialDesignIcon,
+ },
+ props: {
+ visible: {
+ type: Boolean,
+ default: true,
+ },
+ config: {
+ type: Object,
+ required: true,
+ },
+ },
+ emits: ["enabled-change", "config-change", "flush"],
+};
+</script>
diff --git a/meshchatx/src/frontend/components/settings/sections/BatterySettingsSection.vue b/meshchatx/src/frontend/components/settings/sections/BatterySettingsSection.vue
new file mode 100644
index 00000000..9cdedb6e
--- /dev/null
+++ b/meshchatx/src/frontend/components/settings/sections/BatterySettingsSection.vue
@@ -0,0 +1,304 @@
+<!-- SPDX-License-Identifier: 0BSD -->
+
+<template>
+ <section v-show="visible" class="settings-section break-inside-avoid">
+ <header class="settings-section__header">
+ <div>
+ <div class="settings-section__eyebrow">{{ $t("settings.battery.eyebrow") }}</div>
+ <h2>{{ $t("settings.battery.title") }}</h2>
+ <p>{{ $t("settings.battery.description") }}</p>
+ </div>
+ </header>
+ <div class="settings-section__body space-y-4">
+ <label class="setting-toggle">
+ <Toggle
+ id="settings-battery-saver-enabled"
+ :model-value="batterySaver.enabled"
+ @update:model-value="$emit('enabled-change', $event)"
+ />
+ <span class="setting-toggle__label">
+ <span class="setting-toggle__title">{{ $t("settings.battery.enabled") }}</span>
+ <span class="setting-toggle__description">{{ $t("settings.battery.enabled_desc") }}</span>
+ </span>
+ </label>
+
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100 pt-2">
+ {{ $t("settings.battery.options_heading") }}
+ </div>
+
+ <label class="setting-toggle">
+ <Toggle
+ id="settings-battery-viz-discovery"
+ :model-value="batterySaver.disableVisualiserDiscovery"
+ @update:model-value="$emit('patch', { disableVisualiserDiscovery: $event })"
+ />
+ <span class="setting-toggle__label">
+ <span class="setting-toggle__title">{{ $t("settings.battery.disable_visualiser_discovery") }}</span>
+ <span class="setting-toggle__description">{{
+ $t("settings.battery.disable_visualiser_discovery_desc")
+ }}</span>
+ </span>
+ </label>
+
+ <label class="setting-toggle">
+ <Toggle
+ id="settings-battery-hide-offline"
+ :model-value="batterySaver.hideOfflineInterfaces"
+ @update:model-value="$emit('patch', { hideOfflineInterfaces: $event })"
+ />
+ <span class="setting-toggle__label">
+ <span class="setting-toggle__title">{{ $t("settings.battery.hide_offline_interfaces") }}</span>
+ <span class="setting-toggle__description">{{
+ $t("settings.battery.hide_offline_interfaces_desc")
+ }}</span>
+ </span>
+ </label>
+
+ <div class="space-y-2">
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
+ {{ $t("settings.battery.max_visualiser_interfaces") }}
+ </div>
+ <p class="text-xs text-gray-500 dark:text-zinc-400">
+ {{ $t("settings.battery.max_visualiser_interfaces_desc") }}
+ </p>
+ <input
+ :value="batterySaver.maxVisualiserInterfaces"
+ type="number"
+ min="0"
+ max="128"
+ class="input-field"
+ @change="
+ $emit('patch', {
+ maxVisualiserInterfaces: Number($event.target.value),
+ })
+ "
+ />
+ </div>
+
+ <div class="space-y-2">
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
+ {{ $t("settings.battery.visualiser_reload_seconds") }}
+ </div>
+ <p class="text-xs text-gray-500 dark:text-zinc-400">
+ {{ $t("settings.battery.visualiser_reload_seconds_desc") }}
+ </p>
+ <input
+ :value="batterySaver.visualiserReloadSeconds"
+ type="number"
+ min="0"
+ max="600"
+ class="input-field"
+ @change="
+ $emit('patch', {
+ visualiserReloadSeconds: Number($event.target.value),
+ })
+ "
+ />
+ </div>
+
+ <label class="setting-toggle">
+ <Toggle
+ id="settings-battery-live-layout"
+ :model-value="batterySaver.disableVisualiserLiveLayout"
+ @update:model-value="$emit('patch', { disableVisualiserLiveLayout: $event })"
+ />
+ <span class="setting-toggle__label">
+ <span class="setting-toggle__title">{{
+ $t("settings.battery.disable_visualiser_live_layout")
+ }}</span>
+ <span class="setting-toggle__description">{{
+ $t("settings.battery.disable_visualiser_live_layout_desc")
+ }}</span>
+ </span>
+ </label>
+
+ <label class="setting-toggle">
+ <Toggle
+ id="settings-battery-bg-poll"
+ :model-value="batterySaver.reduceBackgroundPolling"
+ @update:model-value="$emit('patch', { reduceBackgroundPolling: $event })"
+ />
+ <span class="setting-toggle__label">
+ <span class="setting-toggle__title">{{ $t("settings.battery.reduce_background_polling") }}</span>
+ <span class="setting-toggle__description">{{
+ $t("settings.battery.reduce_background_polling_desc")
+ }}</span>
+ </span>
+ </label>
+
+ <div class="space-y-2">
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
+ {{ $t("settings.battery.background_poll_multiplier") }}
+ </div>
+ <p class="text-xs text-gray-500 dark:text-zinc-400">
+ {{ $t("settings.battery.background_poll_multiplier_desc") }}
+ </p>
+ <input
+ :value="batterySaver.backgroundPollMultiplier"
+ type="number"
+ min="2"
+ max="10"
+ class="input-field"
+ @change="
+ $emit('patch', {
+ backgroundPollMultiplier: Number($event.target.value),
+ })
+ "
+ />
+ </div>
+
+ <label class="setting-toggle">
+ <Toggle
+ id="settings-battery-ifaces-discovery"
+ :model-value="batterySaver.reduceInterfacesDiscovery"
+ @update:model-value="$emit('patch', { reduceInterfacesDiscovery: $event })"
+ />
+ <span class="setting-toggle__label">
+ <span class="setting-toggle__title">{{ $t("settings.battery.reduce_interfaces_discovery") }}</span>
+ <span class="setting-toggle__description">{{
+ $t("settings.battery.reduce_interfaces_discovery_desc")
+ }}</span>
+ </span>
+ </label>
+
+ <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
+ <div class="space-y-2">
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
+ {{ $t("settings.battery.interfaces_stats_poll_seconds") }}
+ </div>
+ <input
+ :value="batterySaver.interfacesStatsPollSeconds"
+ type="number"
+ min="1"
+ max="120"
+ class="input-field"
+ @change="
+ $emit('patch', {
+ interfacesStatsPollSeconds: Number($event.target.value),
+ })
+ "
+ />
+ </div>
+ <div class="space-y-2">
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
+ {{ $t("settings.battery.interfaces_discovery_poll_seconds") }}
+ </div>
+ <input
+ :value="batterySaver.interfacesDiscoveryPollSeconds"
+ type="number"
+ min="5"
+ max="300"
+ class="input-field"
+ @change="
+ $emit('patch', {
+ interfacesDiscoveryPollSeconds: Number($event.target.value),
+ })
+ "
+ />
+ </div>
+ </div>
+
+ <label class="setting-toggle">
+ <Toggle
+ id="settings-battery-bitrate-limits"
+ :model-value="batterySaver.applyInterfaceBitrateLimits"
+ @update:model-value="$emit('patch', { applyInterfaceBitrateLimits: $event === true })"
+ />
+ <span class="setting-toggle__label">
+ <span class="setting-toggle__title">{{
+ $t("settings.battery.apply_interface_bitrate_limits")
+ }}</span>
+ <span class="setting-toggle__description">{{
+ $t("settings.battery.apply_interface_bitrate_limits_desc")
+ }}</span>
+ </span>
+ </label>
+
+ <div v-if="batterySaver.applyInterfaceBitrateLimits" class="space-y-3">
+ <p class="text-xs text-gray-500 dark:text-zinc-400">
+ {{ $t("settings.battery.interface_bitrate_limits_help") }}
+ </p>
+ <div v-if="batteryInterfaceRows.length === 0" class="text-xs text-gray-500 dark:text-zinc-400">
+ {{ $t("settings.battery.interface_bitrate_limits_empty") }}
+ </div>
+ <div
+ v-for="row in batteryInterfaceRows"
+ :key="row.name"
+ class="grid grid-cols-1 sm:grid-cols-[1fr_10rem] gap-2 items-center"
+ >
+ <div class="text-sm text-gray-900 dark:text-gray-100 truncate" :title="row.name">
+ {{ row.name }}
+ <span class="text-xs text-gray-500 dark:text-zinc-400"> ({{ row.type || "?" }}) </span>
+ </div>
+ <input
+ :value="batterySaver.interfaceBitrateLimits[row.name]"
+ type="number"
+ min="0"
+ class="input-field"
+ :placeholder="$t('settings.battery.interface_bitrate_placeholder')"
+ @change="onBitrateInput(row.name, $event.target.value)"
+ />
+ </div>
+ <div class="flex flex-wrap gap-2">
+ <button
+ type="button"
+ class="secondary-button text-sm"
+ :disabled="batteryBitrateBusy"
+ @click="$emit('apply-bitrates')"
+ >
+ {{ $t("settings.battery.apply_bitrates_reload") }}
+ </button>
+ <button
+ type="button"
+ class="secondary-button text-sm"
+ :disabled="batteryBitrateBusy"
+ @click="$emit('restore-bitrates')"
+ >
+ {{ $t("settings.battery.restore_bitrates_reload") }}
+ </button>
+ </div>
+ </div>
+ </div>
+ </section>
+</template>
+
+<script>
+import Toggle from "../../forms/Toggle.vue";
+
+export default {
+ name: "BatterySettingsSection",
+ components: {
+ Toggle,
+ },
+ props: {
+ visible: {
+ type: Boolean,
+ default: true,
+ },
+ batterySaver: {
+ type: Object,
+ required: true,
+ },
+ batteryInterfaceRows: {
+ type: Array,
+ default: () => [],
+ },
+ batteryBitrateBusy: {
+ type: Boolean,
+ default: false,
+ },
+ },
+ emits: ["enabled-change", "patch", "bitrate-limit-change", "apply-bitrates", "restore-bitrates"],
+ methods: {
+ onBitrateInput(name, raw) {
+ const limits = { ...(this.batterySaver.interfaceBitrateLimits || {}) };
+ if (raw === "" || raw == null || Number.isNaN(Number(raw))) {
+ delete limits[name];
+ } else {
+ limits[name] = Math.max(0, Math.round(Number(raw)));
+ }
+ this.$emit("patch", { interfaceBitrateLimits: limits });
+ },
+ },
+};
+</script>
diff --git a/meshchatx/src/frontend/components/settings/sections/BlockedSettingsSection.vue b/meshchatx/src/frontend/components/settings/sections/BlockedSettingsSection.vue
new file mode 100644
index 00000000..ac6bea61
--- /dev/null
+++ b/meshchatx/src/frontend/components/settings/sections/BlockedSettingsSection.vue
@@ -0,0 +1,31 @@
+<!-- SPDX-License-Identifier: 0BSD -->
+
+<template>
+ <section v-show="visible" class="settings-section break-inside-avoid">
+ <header class="settings-section__header">
+ <div>
+ <div class="settings-section__eyebrow">Privacy</div>
+ <h2>Banished</h2>
+ <p>Manage Banished users and nodes</p>
+ </div>
+ <RouterLink :to="{ name: 'blocked' }" class="primary-chip"> Manage Banished </RouterLink>
+ </header>
+ <div class="settings-section__body">
+ <p class="text-sm text-gray-600 dark:text-gray-400">
+ Banished users and nodes will not be able to send you messages, and their announces will be ignored.
+ </p>
+ </div>
+ </section>
+</template>
+
+<script>
+export default {
+ name: "BlockedSettingsSection",
+ props: {
+ visible: {
+ type: Boolean,
+ default: true,
+ },
+ },
+};
+</script>
diff --git a/meshchatx/src/frontend/components/settings/sections/VisualiserSettingsSection.vue b/meshchatx/src/frontend/components/settings/sections/VisualiserSettingsSection.vue
new file mode 100644
index 00000000..749e2bbb
--- /dev/null
+++ b/meshchatx/src/frontend/components/settings/sections/VisualiserSettingsSection.vue
@@ -0,0 +1,83 @@
+<!-- SPDX-License-Identifier: 0BSD -->
+
+<template>
+ <section v-show="visible" class="settings-section break-inside-avoid">
+ <header class="settings-section__header">
+ <div>
+ <div class="settings-section__eyebrow">Visualiser</div>
+ <h2>{{ $t("visualiser.title") }}</h2>
+ <p>{{ $t("visualiser.description") }}</p>
+ </div>
+ </header>
+ <div class="settings-section__body space-y-4">
+ <div class="space-y-2">
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
+ {{ $t("visualiser.renderer_title") }}
+ </div>
+ <p class="text-xs text-gray-600 dark:text-gray-400">
+ {{ $t("visualiser.renderer_desc") }}
+ </p>
+ <select
+ id="settings-visualiser-renderer"
+ :value="renderer"
+ class="input-field"
+ @change="$emit('renderer-change', $event.target.value)"
+ >
+ <option value="auto">{{ $t("visualiser.renderer_option_auto") }}</option>
+ <option value="webgl">{{ $t("visualiser.renderer_option_webgl") }}</option>
+ <option value="vis">{{ $t("visualiser.renderer_option_vis") }}</option>
+ </select>
+ </div>
+ <label class="setting-toggle">
+ <Toggle
+ id="settings-visualiser-offline"
+ :model-value="showDisabledInterfaces"
+ @update:model-value="$emit('show-disabled-change', $event)"
+ />
+ <span class="setting-toggle__label">
+ <span class="setting-toggle__title">{{ $t("visualiser.show_disabled_interfaces") }}</span>
+ </span>
+ </label>
+ <label class="setting-toggle">
+ <Toggle
+ id="settings-visualiser-discovered"
+ :model-value="showDiscoveredInterfaces"
+ @update:model-value="$emit('show-discovered-change', $event)"
+ />
+ <span class="setting-toggle__label">
+ <span class="setting-toggle__title">{{ $t("visualiser.show_discovered_interfaces") }}</span>
+ </span>
+ </label>
+ </div>
+ </section>
+</template>
+
+<script>
+import Toggle from "../../forms/Toggle.vue";
+
+export default {
+ name: "VisualiserSettingsSection",
+ components: {
+ Toggle,
+ },
+ props: {
+ visible: {
+ type: Boolean,
+ default: true,
+ },
+ renderer: {
+ type: String,
+ default: "auto",
+ },
+ showDisabledInterfaces: {
+ type: Boolean,
+ default: false,
+ },
+ showDiscoveredInterfaces: {
+ type: Boolean,
+ default: false,
+ },
+ },
+ emits: ["renderer-change", "show-disabled-change", "show-discovered-change"],
+};
+</script>
diff --git a/tests/frontend/UIComponents.test.js b/tests/frontend/UIComponents.test.js
index ceed6710..c97935d5 100644
--- a/tests/frontend/UIComponents.test.js
+++ b/tests/frontend/UIComponents.test.js
@@ -1,7 +1,7 @@
import { mount } from "@vue/test-utils";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import IconButton from "../../meshchatx/src/frontend/components/IconButton.vue";
-import SendMessageButton from "../../meshchatx/src/frontend/components/messages/SendMessageButton.vue";
+import SendMessageButton from "../../meshchatx/src/frontend/components/messages/composer/SendMessageButton.vue";
import Toggle from "../../meshchatx/src/frontend/components/forms/Toggle.vue";
import FormLabel from "../../meshchatx/src/frontend/components/forms/FormLabel.vue";
import FormSubLabel from "../../meshchatx/src/frontend/components/forms/FormSubLabel.vue";
diff --git a/tests/frontend/fixtures/frontend_mega_page_ownership.json b/tests/frontend/fixtures/frontend_mega_page_ownership.json
new file mode 100644
index 00000000..3d1a6c70
--- /dev/null
+++ b/tests/frontend/fixtures/frontend_mega_page_ownership.json
@@ -0,0 +1,172 @@
+{
+ "version": 1,
+ "pages": [
+ {
+ "id": "settings",
+ "shell": "meshchatx/src/frontend/components/settings/SettingsPage.vue",
+ "allowed_child_dirs": [
+ "meshchatx/src/frontend/components/settings/sections",
+ "meshchatx/src/frontend/js/settings"
+ ],
+ "required_children": [
+ "meshchatx/src/frontend/components/settings/SettingToggleRow.vue",
+ "meshchatx/src/frontend/components/settings/SettingsSectionBlock.vue",
+ "meshchatx/src/frontend/components/settings/sections/LanguageSettingsSection.vue",
+ "meshchatx/src/frontend/components/settings/sections/DesktopSettingsSection.vue",
+ "meshchatx/src/frontend/components/settings/sections/StrangerProtectionSettingsSection.vue",
+ "meshchatx/src/frontend/components/settings/sections/BanishmentSettingsSection.vue",
+ "meshchatx/src/frontend/components/settings/sections/StickersSettingsSection.vue",
+ "meshchatx/src/frontend/components/settings/sections/GifsSettingsSection.vue",
+ "meshchatx/src/frontend/components/settings/sections/TelephonySettingsSection.vue",
+ "meshchatx/src/frontend/components/settings/sections/AppearanceSettingsSection.vue",
+ "meshchatx/src/frontend/components/settings/sections/BatterySettingsSection.vue",
+ "meshchatx/src/frontend/components/settings/sections/VisualiserSettingsSection.vue",
+ "meshchatx/src/frontend/components/settings/sections/BlockedSettingsSection.vue",
+ "meshchatx/src/frontend/components/settings/sections/AndroidSettingsSection.vue",
+ "meshchatx/src/frontend/components/settings/sections/ArchiverSettingsSection.vue",
+ "meshchatx/src/frontend/components/settings/PluginsSettingsSection.vue"
+ ],
+ "required_shell_imports": [
+ "LanguageSettingsSection",
+ "DesktopSettingsSection",
+ "StrangerProtectionSettingsSection",
+ "BanishmentSettingsSection",
+ "StickersSettingsSection",
+ "GifsSettingsSection",
+ "TelephonySettingsSection",
+ "AppearanceSettingsSection",
+ "BatterySettingsSection",
+ "VisualiserSettingsSection",
+ "BlockedSettingsSection",
+ "AndroidSettingsSection",
+ "ArchiverSettingsSection",
+ "PluginsSettingsSection",
+ "SettingsSectionBlock"
+ ]
+ },
+ {
+ "id": "map",
+ "shell": "meshchatx/src/frontend/components/map/MapPage.vue",
+ "allowed_child_dirs": [
+ "meshchatx/src/frontend/components/map/internal",
+ "meshchatx/src/frontend/js"
+ ],
+ "required_children": [
+ "meshchatx/src/frontend/components/map/internal/MapClusterPanel.vue",
+ "meshchatx/src/frontend/components/map/internal/MapMarkerPanel.vue",
+ "meshchatx/src/frontend/components/map/internal/MapDrawingToolbar.vue",
+ "meshchatx/src/frontend/components/map/internal/MapBearingInstructions.vue",
+ "meshchatx/src/frontend/components/map/internal/MapSearchBar.vue",
+ "meshchatx/src/frontend/components/map/internal/MapExportInstructions.vue",
+ "meshchatx/src/frontend/components/map/internal/MapExportConfigPanel.vue",
+ "meshchatx/src/frontend/components/map/internal/MapExportProgressPanel.vue",
+ "meshchatx/src/frontend/components/map/internal/MapLoadingOverlay.vue",
+ "meshchatx/src/frontend/components/map/internal/MapVectorExchangePanel.vue",
+ "meshchatx/src/frontend/components/map/internal/MapRemoteOverlayPanel.vue",
+ "meshchatx/src/frontend/components/map/internal/MapSaveDrawingModal.vue",
+ "meshchatx/src/frontend/components/map/internal/MapLoadDrawingModal.vue",
+ "meshchatx/src/frontend/components/map/internal/MapMobileNoteModal.vue",
+ "meshchatx/src/frontend/components/map/internal/clusterUtils.js",
+ "meshchatx/src/frontend/components/map/internal/discoveredIcons.js",
+ "meshchatx/src/frontend/components/map/internal/mapDedupe.js",
+ "meshchatx/src/frontend/components/map/internal/markerStyles.js"
+ ],
+ "required_shell_imports": [
+ "MapClusterPanel",
+ "MapMarkerPanel",
+ "MapDrawingToolbar",
+ "MapBearingInstructions",
+ "MapSearchBar",
+ "MapExportInstructions",
+ "MapExportConfigPanel",
+ "MapExportProgressPanel",
+ "MapLoadingOverlay",
+ "MapVectorExchangePanel",
+ "MapRemoteOverlayPanel",
+ "MapSaveDrawingModal",
+ "MapLoadDrawingModal",
+ "MapMobileNoteModal",
+ "clusterUtils",
+ "discoveredIcons",
+ "mapDedupe",
+ "markerStyles"
+ ]
+ },
+ {
+ "id": "messages",
+ "shell": "meshchatx/src/frontend/components/messages/ConversationViewer.vue",
+ "allowed_child_dirs": [
+ "meshchatx/src/frontend/components/messages",
+ "meshchatx/src/frontend/components/messages/composer",
+ "meshchatx/src/frontend/components/messages/modals",
+ "meshchatx/src/frontend/components/messages/outbound",
+ "meshchatx/src/frontend/components/messages/lxmf",
+ "meshchatx/src/frontend/components/messages/telemetry"
+ ],
+ "required_children": [
+ "meshchatx/src/frontend/components/messages/ConversationPeerHeader.vue",
+ "meshchatx/src/frontend/components/messages/ConversationMessageEntry.vue",
+ "meshchatx/src/frontend/components/messages/conversationMessageHelpers.js",
+ "meshchatx/src/frontend/components/messages/outbound/OutboundTransferProgressFooter.vue",
+ "meshchatx/src/frontend/components/messages/modals/ShareContactModal.vue",
+ "meshchatx/src/frontend/components/messages/modals/ConversationImageModal.vue",
+ "meshchatx/src/frontend/components/messages/composer/AddImageButton.vue",
+ "meshchatx/src/frontend/components/messages/lxmf/contactDisplay.js",
+ "meshchatx/src/frontend/components/messages/lxmf/normalize.js"
+ ],
+ "required_shell_imports": [
+ "ConversationPeerHeader",
+ "ConversationMessageEntry",
+ "conversationMessageHelpers",
+ "ShareContactModal",
+ "ConversationImageModal",
+ "AddImageButton",
+ "contactDisplay",
+ "normalize"
+ ]
+ },
+ {
+ "id": "call",
+ "shell": "meshchatx/src/frontend/components/call/CallPage.vue",
+ "allowed_child_dirs": [
+ "meshchatx/src/frontend/components/call",
+ "meshchatx/src/frontend/components/call/tabs",
+ "meshchatx/src/frontend/components/call/audio"
+ ],
+ "required_children": [
+ "meshchatx/src/frontend/components/call/CallOverlay.vue",
+ "meshchatx/src/frontend/components/call/audio/RingtoneEditor.vue",
+ "meshchatx/src/frontend/components/call/tabs/CallPhonebookTab.vue",
+ "meshchatx/src/frontend/components/call/tabs/CallContactsTab.vue",
+ "meshchatx/src/frontend/components/call/tabs/CallVoicemailTab.vue",
+ "meshchatx/src/frontend/components/call/tabs/CallTabBar.vue"
+ ],
+ "required_shell_imports": [
+ "RingtoneEditor",
+ "CallPhonebookTab",
+ "CallContactsTab",
+ "CallVoicemailTab",
+ "CallTabBar"
+ ]
+ },
+ {
+ "id": "app",
+ "shell": "meshchatx/src/frontend/components/App.vue",
+ "allowed_child_dirs": [
+ "meshchatx/src/frontend/components/layout",
+ "meshchatx/src/frontend/components/call"
+ ],
+ "required_children": [
+ "meshchatx/src/frontend/components/layout/AppShellBanners.vue",
+ "meshchatx/src/frontend/components/layout/AppIdentitySwitchOverlay.vue",
+ "meshchatx/src/frontend/components/call/CallOverlay.vue"
+ ],
+ "required_shell_imports": [
+ "AppShellBanners",
+ "AppIdentitySwitchOverlay",
+ "CallOverlay",
+ "listNavItems"
+ ]
+ }
+ ]
+}
diff --git a/tests/frontend/fixtures/frontend_symbol_continuity/app.json b/tests/frontend/fixtures/frontend_symbol_continuity/app.json
new file mode 100644
index 00000000..39397365
--- /dev/null
+++ b/tests/frontend/fixtures/frontend_symbol_continuity/app.json
@@ -0,0 +1,16 @@
+{
+ "id": "app",
+ "scan_dirs": [
+ "meshchatx/src/frontend/components/App.vue",
+ "meshchatx/src/frontend/components/layout",
+ "meshchatx/src/frontend/main.js",
+ "meshchatx/src/frontend/js/registries"
+ ],
+ "required_strings": [
+ "AppShellBanners",
+ "AppIdentitySwitchOverlay",
+ "registerCoreContributions",
+ "listNavItems",
+ "CallOverlay"
+ ]
+}
diff --git a/tests/frontend/fixtures/frontend_symbol_continuity/call.json b/tests/frontend/fixtures/frontend_symbol_continuity/call.json
new file mode 100644
index 00000000..e058acd1
--- /dev/null
+++ b/tests/frontend/fixtures/frontend_symbol_continuity/call.json
@@ -0,0 +1,15 @@
+{
+ "id": "call",
+ "scan_dirs": ["meshchatx/src/frontend/components/call"],
+ "required_strings": [
+ "CallOverlay",
+ "RingtoneEditor",
+ "CallPhonebookTab",
+ "CallContactsTab",
+ "CallVoicemailTab",
+ "CallTabBar",
+ "/api/v1/telephone",
+ "call.load_more",
+ "call.ringtone"
+ ]
+}
diff --git a/tests/frontend/fixtures/frontend_symbol_continuity/map.json b/tests/frontend/fixtures/frontend_symbol_continuity/map.json
new file mode 100644
index 00000000..810c8df7
--- /dev/null
+++ b/tests/frontend/fixtures/frontend_symbol_continuity/map.json
@@ -0,0 +1,25 @@
+{
+ "id": "map",
+ "scan_dirs": ["meshchatx/src/frontend/components/map"],
+ "required_strings": [
+ "MapClusterPanel",
+ "MapMarkerPanel",
+ "MapDrawingToolbar",
+ "MapSearchBar",
+ "MapLoadingOverlay",
+ "MapRemoteOverlayPanel",
+ "MapSaveDrawingModal",
+ "MapLoadDrawingModal",
+ "MapMobileNoteModal",
+ "extentDiagonal",
+ "buildClusterItems",
+ "getDiscoveredIconName",
+ "dedupeDiscoveredMapNodes",
+ "dedupeTelemetryMarkersForMap",
+ "peerBadgeStyle",
+ "clusterBadgeStyle",
+ "map.title",
+ "map.save_drawing_title",
+ "map.load_drawing_title"
+ ]
+}
diff --git a/tests/frontend/fixtures/frontend_symbol_continuity/messages.json b/tests/frontend/fixtures/frontend_symbol_continuity/messages.json
new file mode 100644
index 00000000..14379310
--- /dev/null
+++ b/tests/frontend/fixtures/frontend_symbol_continuity/messages.json
@@ -0,0 +1,25 @@
+{
+ "id": "messages",
+ "scan_dirs": ["meshchatx/src/frontend/components/messages"],
+ "required_strings": [
+ "canCancelOutboundSend",
+ "cancelSendingMessage",
+ "messages.cancel_send",
+ "/cancel",
+ "cancelKey:",
+ "DownloadUtils",
+ "NomadRichHtmlLinks",
+ "handleRichHtmlLinkClick",
+ "ConversationPeerHeader",
+ "ConversationMessageEntry",
+ "OutboundTransferProgressFooter",
+ "conversationMessageHelpers",
+ "ShareContactModal",
+ "ConversationImageModal",
+ "lxmfContactResolvedIcon",
+ "contactDisplay",
+ "normalizeLxmfMessage",
+ "normalizeSidebandCommandKey",
+ "normalize"
+ ]
+}
diff --git a/tests/frontend/fixtures/frontend_symbol_continuity/settings.json b/tests/frontend/fixtures/frontend_symbol_continuity/settings.json
new file mode 100644
index 00000000..b8d99a65
--- /dev/null
+++ b/tests/frontend/fixtures/frontend_symbol_continuity/settings.json
@@ -0,0 +1,29 @@
+{
+ "id": "settings",
+ "scan_dirs": ["meshchatx/src/frontend/components/settings", "meshchatx/src/frontend/js/settings"],
+ "required_strings": [
+ "LanguageSettingsSection",
+ "DesktopSettingsSection",
+ "StrangerProtectionSettingsSection",
+ "BanishmentSettingsSection",
+ "StickersSettingsSection",
+ "GifsSettingsSection",
+ "TelephonySettingsSection",
+ "AppearanceSettingsSection",
+ "BatterySettingsSection",
+ "VisualiserSettingsSection",
+ "BlockedSettingsSection",
+ "AndroidSettingsSection",
+ "ArchiverSettingsSection",
+ "PluginsSettingsSection",
+ "SettingsSectionBlock",
+ "SettingToggleRow",
+ "settingsConfigService",
+ "/api/v1/config",
+ "app.appearance",
+ "settings.battery.enabled",
+ "visualiser.title",
+ "settings.android_privacy_heading",
+ "Page Archiver"
+ ]
+}
diff --git a/tests/frontend/frontendOwnershipContract.test.js b/tests/frontend/frontendOwnershipContract.test.js
new file mode 100644
index 00000000..0b7fd1e1
--- /dev/null
+++ b/tests/frontend/frontendOwnershipContract.test.js
@@ -0,0 +1,72 @@
+// SPDX-License-Identifier: 0BSD
+
+import { existsSync, readFileSync, writeFileSync } from "fs";
+import { join } from "path";
+import { describe, expect, it } from "vitest";
+import {
+ assertStringsInSources,
+ discoverMegaPageOwnership,
+ joinFeatureSources,
+ loadJsonFixture,
+ readUtf8,
+ writeOwnershipFixture,
+} from "./helpers/frontendOwnershipHelpers.js";
+
+const repoRoot = process.cwd();
+const ownershipFixtureRel = "tests/frontend/fixtures/frontend_mega_page_ownership.json";
+const continuityDirRel = "tests/frontend/fixtures/frontend_symbol_continuity";
+
+describe("frontend mega-page ownership contracts", () => {
+ it("ownership_fixture matches discovered on-disk inventory", () => {
+ const fixturePath = join(repoRoot, ownershipFixtureRel);
+ const discovered = discoverMegaPageOwnership(repoRoot);
+ if (process.env.UPDATE_FRONTEND_OWNERSHIP === "1") {
+ writeOwnershipFixture(repoRoot, fixturePath);
+ }
+ expect(existsSync(fixturePath)).toBe(true);
+ const fixture = JSON.parse(readFileSync(fixturePath, "utf8"));
+ expect(fixture).toEqual(discovered);
+ });
+
+ it("required children exist and shells import declared symbols", () => {
+ const fixture = loadJsonFixture(repoRoot, ownershipFixtureRel);
+ for (const page of fixture.pages) {
+ for (const child of page.required_children) {
+ expect(existsSync(join(repoRoot, child)), `${page.id} missing child ${child}`).toBe(true);
+ }
+ const shellSrc = readUtf8(join(repoRoot, page.shell));
+ for (const symbol of page.required_shell_imports) {
+ expect(shellSrc.includes(symbol), `${page.id} shell missing import/use of ${symbol}`).toBe(true);
+ }
+ }
+ });
+
+ it("symbol continuity fixtures remain present in feature trees", () => {
+ const pages = ["settings", "map", "messages", "call", "app"];
+ for (const id of pages) {
+ const continuity = loadJsonFixture(repoRoot, `${continuityDirRel}/${id}.json`);
+ const sourceText = joinFeatureSources(repoRoot, continuity.scan_dirs);
+ expect(() =>
+ assertStringsInSources(sourceText, continuity.required_strings, `${id} continuity`)
+ ).not.toThrow();
+ }
+ });
+
+ it("ownership pages do not invent unknown top-level child roots", () => {
+ const fixture = loadJsonFixture(repoRoot, ownershipFixtureRel);
+ const allowedRoots = [
+ "meshchatx/src/frontend/components/settings",
+ "meshchatx/src/frontend/components/map",
+ "meshchatx/src/frontend/components/messages",
+ "meshchatx/src/frontend/components/call",
+ "meshchatx/src/frontend/components/layout",
+ "meshchatx/src/frontend/js",
+ ];
+ for (const page of fixture.pages) {
+ for (const dir of page.allowed_child_dirs) {
+ const ok = allowedRoots.some((root) => dir === root || dir.startsWith(`${root}/`));
+ expect(ok, `unexpected allowed dir ${dir} for ${page.id}`).toBe(true);
+ }
+ }
+ });
+});
diff --git a/tests/frontend/helpers/frontendOwnershipHelpers.js b/tests/frontend/helpers/frontendOwnershipHelpers.js
new file mode 100644
index 00000000..49c585b6
--- /dev/null
+++ b/tests/frontend/helpers/frontendOwnershipHelpers.js
@@ -0,0 +1,237 @@
+// SPDX-License-Identifier: 0BSD
+
+import { existsSync, readdirSync, readFileSync, writeFileSync, statSync } from "fs";
+import { join, relative } from "path";
+
+export function repoRootFromCwd(cwd = process.cwd()) {
+ return cwd;
+}
+
+export function readUtf8(absolutePath) {
+ return readFileSync(absolutePath, "utf8");
+}
+
+export function listFilesRecursive(dir, exts) {
+ const out = [];
+ if (!existsSync(dir)) {
+ return out;
+ }
+ for (const name of readdirSync(dir)) {
+ const full = join(dir, name);
+ const st = statSync(full);
+ if (st.isDirectory()) {
+ out.push(...listFilesRecursive(full, exts));
+ continue;
+ }
+ if (exts.some((ext) => name.endsWith(ext))) {
+ out.push(full);
+ }
+ }
+ return out;
+}
+
+export function joinFeatureSources(repoRoot, relativeDirs) {
+ const chunks = [];
+ for (const rel of relativeDirs) {
+ const abs = join(repoRoot, rel);
+ if (statSync(abs, { throwIfNoEntry: false })?.isFile()) {
+ chunks.push(readUtf8(abs));
+ continue;
+ }
+ for (const file of listFilesRecursive(abs, [".vue", ".js"])) {
+ chunks.push(readUtf8(file));
+ }
+ }
+ return chunks.join("\n");
+}
+
+export function discoverMegaPageOwnership(repoRoot) {
+ const pages = [
+ {
+ id: "settings",
+ shell: "meshchatx/src/frontend/components/settings/SettingsPage.vue",
+ allowed_child_dirs: [
+ "meshchatx/src/frontend/components/settings/sections",
+ "meshchatx/src/frontend/js/settings",
+ ],
+ required_children: [
+ "meshchatx/src/frontend/components/settings/SettingToggleRow.vue",
+ "meshchatx/src/frontend/components/settings/SettingsSectionBlock.vue",
+ "meshchatx/src/frontend/components/settings/sections/LanguageSettingsSection.vue",
+ "meshchatx/src/frontend/components/settings/sections/DesktopSettingsSection.vue",
+ "meshchatx/src/frontend/components/settings/sections/StrangerProtectionSettingsSection.vue",
+ "meshchatx/src/frontend/components/settings/sections/BanishmentSettingsSection.vue",
+ "meshchatx/src/frontend/components/settings/sections/StickersSettingsSection.vue",
+ "meshchatx/src/frontend/components/settings/sections/GifsSettingsSection.vue",
+ "meshchatx/src/frontend/components/settings/sections/TelephonySettingsSection.vue",
+ "meshchatx/src/frontend/components/settings/sections/AppearanceSettingsSection.vue",
+ "meshchatx/src/frontend/components/settings/sections/BatterySettingsSection.vue",
+ "meshchatx/src/frontend/components/settings/sections/VisualiserSettingsSection.vue",
+ "meshchatx/src/frontend/components/settings/sections/BlockedSettingsSection.vue",
+ "meshchatx/src/frontend/components/settings/sections/AndroidSettingsSection.vue",
+ "meshchatx/src/frontend/components/settings/sections/ArchiverSettingsSection.vue",
+ "meshchatx/src/frontend/components/settings/PluginsSettingsSection.vue",
+ ],
+ required_shell_imports: [
+ "LanguageSettingsSection",
+ "DesktopSettingsSection",
+ "StrangerProtectionSettingsSection",
+ "BanishmentSettingsSection",
+ "StickersSettingsSection",
+ "GifsSettingsSection",
+ "TelephonySettingsSection",
+ "AppearanceSettingsSection",
+ "BatterySettingsSection",
+ "VisualiserSettingsSection",
+ "BlockedSettingsSection",
+ "AndroidSettingsSection",
+ "ArchiverSettingsSection",
+ "PluginsSettingsSection",
+ "SettingsSectionBlock",
+ ],
+ },
+ {
+ id: "map",
+ shell: "meshchatx/src/frontend/components/map/MapPage.vue",
+ allowed_child_dirs: ["meshchatx/src/frontend/components/map/internal", "meshchatx/src/frontend/js"],
+ required_children: [
+ "meshchatx/src/frontend/components/map/internal/MapClusterPanel.vue",
+ "meshchatx/src/frontend/components/map/internal/MapMarkerPanel.vue",
+ "meshchatx/src/frontend/components/map/internal/MapDrawingToolbar.vue",
+ "meshchatx/src/frontend/components/map/internal/MapBearingInstructions.vue",
+ "meshchatx/src/frontend/components/map/internal/MapSearchBar.vue",
+ "meshchatx/src/frontend/components/map/internal/MapExportInstructions.vue",
+ "meshchatx/src/frontend/components/map/internal/MapExportConfigPanel.vue",
+ "meshchatx/src/frontend/components/map/internal/MapExportProgressPanel.vue",
+ "meshchatx/src/frontend/components/map/internal/MapLoadingOverlay.vue",
+ "meshchatx/src/frontend/components/map/internal/MapVectorExchangePanel.vue",
+ "meshchatx/src/frontend/components/map/internal/MapRemoteOverlayPanel.vue",
+ "meshchatx/src/frontend/components/map/internal/MapSaveDrawingModal.vue",
+ "meshchatx/src/frontend/components/map/internal/MapLoadDrawingModal.vue",
+ "meshchatx/src/frontend/components/map/internal/MapMobileNoteModal.vue",
+ "meshchatx/src/frontend/components/map/internal/clusterUtils.js",
+ "meshchatx/src/frontend/components/map/internal/discoveredIcons.js",
+ "meshchatx/src/frontend/components/map/internal/mapDedupe.js",
+ "meshchatx/src/frontend/components/map/internal/markerStyles.js",
+ ],
+ required_shell_imports: [
+ "MapClusterPanel",
+ "MapMarkerPanel",
+ "MapDrawingToolbar",
+ "MapBearingInstructions",
+ "MapSearchBar",
+ "MapExportInstructions",
+ "MapExportConfigPanel",
+ "MapExportProgressPanel",
+ "MapLoadingOverlay",
+ "MapVectorExchangePanel",
+ "MapRemoteOverlayPanel",
+ "MapSaveDrawingModal",
+ "MapLoadDrawingModal",
+ "MapMobileNoteModal",
+ "clusterUtils",
+ "discoveredIcons",
+ "mapDedupe",
+ "markerStyles",
+ ],
+ },
+ {
+ id: "messages",
+ shell: "meshchatx/src/frontend/components/messages/ConversationViewer.vue",
+ allowed_child_dirs: [
+ "meshchatx/src/frontend/components/messages",
+ "meshchatx/src/frontend/components/messages/composer",
+ "meshchatx/src/frontend/components/messages/modals",
+ "meshchatx/src/frontend/components/messages/outbound",
+ "meshchatx/src/frontend/components/messages/lxmf",
+ "meshchatx/src/frontend/components/messages/telemetry",
+ ],
+ required_children: [
+ "meshchatx/src/frontend/components/messages/ConversationPeerHeader.vue",
+ "meshchatx/src/frontend/components/messages/ConversationMessageEntry.vue",
+ "meshchatx/src/frontend/components/messages/conversationMessageHelpers.js",
+ "meshchatx/src/frontend/components/messages/outbound/OutboundTransferProgressFooter.vue",
+ "meshchatx/src/frontend/components/messages/modals/ShareContactModal.vue",
+ "meshchatx/src/frontend/components/messages/modals/ConversationImageModal.vue",
+ "meshchatx/src/frontend/components/messages/composer/AddImageButton.vue",
+ "meshchatx/src/frontend/components/messages/lxmf/contactDisplay.js",
+ "meshchatx/src/frontend/components/messages/lxmf/normalize.js",
+ ],
+ required_shell_imports: [
+ "ConversationPeerHeader",
+ "ConversationMessageEntry",
+ "conversationMessageHelpers",
+ "ShareContactModal",
+ "ConversationImageModal",
+ "AddImageButton",
+ "contactDisplay",
+ "normalize",
+ ],
+ },
+ {
+ id: "call",
+ shell: "meshchatx/src/frontend/components/call/CallPage.vue",
+ allowed_child_dirs: [
+ "meshchatx/src/frontend/components/call",
+ "meshchatx/src/frontend/components/call/tabs",
+ "meshchatx/src/frontend/components/call/audio",
+ ],
+ required_children: [
+ "meshchatx/src/frontend/components/call/CallOverlay.vue",
+ "meshchatx/src/frontend/components/call/audio/RingtoneEditor.vue",
+ "meshchatx/src/frontend/components/call/tabs/CallPhonebookTab.vue",
+ "meshchatx/src/frontend/components/call/tabs/CallContactsTab.vue",
+ "meshchatx/src/frontend/components/call/tabs/CallVoicemailTab.vue",
+ "meshchatx/src/frontend/components/call/tabs/CallTabBar.vue",
+ ],
+ required_shell_imports: [
+ "RingtoneEditor",
+ "CallPhonebookTab",
+ "CallContactsTab",
+ "CallVoicemailTab",
+ "CallTabBar",
+ ],
+ },
+ {
+ id: "app",
+ shell: "meshchatx/src/frontend/components/App.vue",
+ allowed_child_dirs: ["meshchatx/src/frontend/components/layout", "meshchatx/src/frontend/components/call"],
+ required_children: [
+ "meshchatx/src/frontend/components/layout/AppShellBanners.vue",
+ "meshchatx/src/frontend/components/layout/AppIdentitySwitchOverlay.vue",
+ "meshchatx/src/frontend/components/call/CallOverlay.vue",
+ ],
+ required_shell_imports: ["AppShellBanners", "AppIdentitySwitchOverlay", "CallOverlay", "listNavItems"],
+ },
+ ];
+
+ return {
+ version: 1,
+ pages: pages.map((page) => ({
+ ...page,
+ required_children: page.required_children.filter((rel) => existsSync(join(repoRoot, rel))),
+ required_shell_imports: page.required_shell_imports,
+ })),
+ };
+}
+
+export function writeOwnershipFixture(repoRoot, fixturePath) {
+ const payload = discoverMegaPageOwnership(repoRoot);
+ writeFileSync(fixturePath, `${JSON.stringify(payload, null, 4)}\n`, "utf8");
+ return payload;
+}
+
+export function loadJsonFixture(repoRoot, relativePath) {
+ return JSON.parse(readUtf8(join(repoRoot, relativePath)));
+}
+
+export function assertStringsInSources(sourceText, requiredStrings, label) {
+ const missing = requiredStrings.filter((s) => !sourceText.includes(s));
+ if (missing.length) {
+ throw new Error(`${label} missing required strings: ${missing.join(", ")}`);
+ }
+}
+
+export function relativeRepoPath(repoRoot, absolutePath) {
+ return relative(repoRoot, absolutePath).split("\\").join("/");
+}
diff --git a/tests/frontend/lxmfNormalize.test.js b/tests/frontend/lxmfNormalize.test.js
new file mode 100644
index 00000000..80cb8c1d
--- /dev/null
+++ b/tests/frontend/lxmfNormalize.test.js
@@ -0,0 +1,86 @@
+// SPDX-License-Identifier: 0BSD
+
+import { describe, expect, it } from "vitest";
+import {
+ normalizeLxmfMessage,
+ normalizeSidebandCommandKey,
+} from "../../meshchatx/src/frontend/components/messages/lxmf/normalize.js";
+
+describe("normalizeLxmfMessage", () => {
+ it("oracle: copies msg and leaves created_at when already set", () => {
+ const msg = { hash: "abc", created_at: "2020-01-01T00:00:00.000Z", timestamp: 1, state: "delivered" };
+ expect(normalizeLxmfMessage(msg, false)).toEqual({
+ hash: "abc",
+ created_at: "2020-01-01T00:00:00.000Z",
+ timestamp: 1,
+ state: "delivered",
+ });
+ expect(normalizeLxmfMessage(msg, false)).not.toBe(msg);
+ });
+
+ it("oracle: derives created_at ISO from unix timestamp when missing", () => {
+ const msg = { hash: "abc", timestamp: 1700000000, state: "delivered" };
+ expect(normalizeLxmfMessage(msg, false)).toEqual({
+ hash: "abc",
+ timestamp: 1700000000,
+ state: "delivered",
+ created_at: new Date(1700000000 * 1000).toISOString(),
+ });
+ });
+
+ it("oracle: outbound unknown state becomes outbound", () => {
+ expect(normalizeLxmfMessage({ state: "unknown" }, true)).toEqual({ state: "outbound" });
+ expect(normalizeLxmfMessage({ state: "unknown" }, false)).toEqual({ state: "unknown" });
+ expect(normalizeLxmfMessage({ state: "delivered" }, true)).toEqual({ state: "delivered" });
+ });
+
+ it("oracle: no timestamp and no created_at leaves created_at unset", () => {
+ expect(normalizeLxmfMessage({ hash: "x" }, false)).toEqual({ hash: "x" });
+ });
+});
+
+describe("normalizeSidebandCommandKey", () => {
+ it("oracle: empty or whitespace-only keys reject", () => {
+ expect(normalizeSidebandCommandKey(null)).toBe(null);
+ expect(normalizeSidebandCommandKey(undefined)).toBe(null);
+ expect(normalizeSidebandCommandKey("")).toBe(null);
+ expect(normalizeSidebandCommandKey(" ")).toBe(null);
+ });
+
+ it("oracle: known aliases map to fixed hex keys", () => {
+ expect(normalizeSidebandCommandKey("plugin")).toBe("0x00");
+ expect(normalizeSidebandCommandKey("PLUGIN")).toBe("0x00");
+ expect(normalizeSidebandCommandKey("telemetry_request")).toBe("0x01");
+ expect(normalizeSidebandCommandKey("request")).toBe("0x01");
+ expect(normalizeSidebandCommandKey("location_request")).toBe("0x01");
+ expect(normalizeSidebandCommandKey("ping")).toBe("0x02");
+ expect(normalizeSidebandCommandKey("echo")).toBe("0x03");
+ expect(normalizeSidebandCommandKey("signal_report")).toBe("0x04");
+ });
+
+ it("oracle: decimal 0-255 becomes zero-padded 0xNN", () => {
+ expect(normalizeSidebandCommandKey("0")).toBe("0x00");
+ expect(normalizeSidebandCommandKey("1")).toBe("0x01");
+ expect(normalizeSidebandCommandKey("255")).toBe("0xff");
+ expect(normalizeSidebandCommandKey("16")).toBe("0x10");
+ });
+
+ it("oracle: decimal outside 0-255 rejects", () => {
+ expect(normalizeSidebandCommandKey("256")).toBe(null);
+ expect(normalizeSidebandCommandKey("-1")).toBe(null);
+ });
+
+ it("oracle: 0x hex forms normalize to lowercase zero-padded", () => {
+ expect(normalizeSidebandCommandKey("0x1")).toBe("0x01");
+ expect(normalizeSidebandCommandKey("0x0A")).toBe("0x0a");
+ expect(normalizeSidebandCommandKey("0xff")).toBe("0xff");
+ expect(normalizeSidebandCommandKey("0xFF")).toBe("0xff");
+ });
+
+ it("oracle: unknown tokens reject", () => {
+ expect(normalizeSidebandCommandKey("nope")).toBe(null);
+ expect(normalizeSidebandCommandKey("0xGGG")).toBe(null);
+ expect(normalizeSidebandCommandKey("0x100")).toBe(null);
+ expect(normalizeSidebandCommandKey("12.5")).toBe(null);
+ });
+});
diff --git a/tests/frontend/settingsSections.test.js b/tests/frontend/settingsSections.test.js
index 0decd11a..5021109c 100644
--- a/tests/frontend/settingsSections.test.js
+++ b/tests/frontend/settingsSections.test.js
@@ -6,6 +6,12 @@ import SettingToggleRow from "../../meshchatx/src/frontend/components/settings/S
import StrangerProtectionSettingsSection from "../../meshchatx/src/frontend/components/settings/sections/StrangerProtectionSettingsSection.vue";
import BanishmentSettingsSection from "../../meshchatx/src/frontend/components/settings/sections/BanishmentSettingsSection.vue";
import TelephonySettingsSection from "../../meshchatx/src/frontend/components/settings/sections/TelephonySettingsSection.vue";
+import AppearanceSettingsSection from "../../meshchatx/src/frontend/components/settings/sections/AppearanceSettingsSection.vue";
+import BatterySettingsSection from "../../meshchatx/src/frontend/components/settings/sections/BatterySettingsSection.vue";
+import VisualiserSettingsSection from "../../meshchatx/src/frontend/components/settings/sections/VisualiserSettingsSection.vue";
+import BlockedSettingsSection from "../../meshchatx/src/frontend/components/settings/sections/BlockedSettingsSection.vue";
+import AndroidSettingsSection from "../../meshchatx/src/frontend/components/settings/sections/AndroidSettingsSection.vue";
+import ArchiverSettingsSection from "../../meshchatx/src/frontend/components/settings/sections/ArchiverSettingsSection.vue";
describe("SettingToggleRow", () => {
it("renders title and description and emits updates", async () => {
@@ -96,3 +102,171 @@ describe("TelephonySettingsSection", () => {
expect(wrapper.emitted("enabled-change")).toEqual([[false]]);
});
});
+
+describe("AppearanceSettingsSection", () => {
+ it("emits theme and field events without mutating config prop", async () => {
+ const config = {
+ theme: "light",
+ messages_sidebar_position: "left",
+ message_font_size: 14,
+ message_icon_size: 28,
+ ui_transparency: 0,
+ ui_glass_enabled: true,
+ messages_multi_pane_enabled: false,
+ nomad_tabs_enabled: false,
+ rrc_enabled: false,
+ rrc_unread_badges_enabled: true,
+ message_outbound_bubble_color: "#4f46e5",
+ message_inbound_bubble_color: null,
+ message_failed_bubble_color: "#ef4444",
+ message_waiting_bubble_color: "#e5e7eb",
+ };
+ const wrapper = mount(AppearanceSettingsSection, {
+ props: {
+ visible: true,
+ config,
+ detailedOutboundSendStatus: false,
+ outboundTransferProgressEnabled: false,
+ messageTimestampGroupingEnabled: false,
+ messageIconPreviewStyle: { width: "28px", height: "28px" },
+ },
+ global: {
+ mocks: { $t: (key) => key },
+ },
+ });
+ expect(wrapper.text()).toContain("app.appearance");
+ const themeSelect = wrapper.find("select");
+ await themeSelect.setValue("dark");
+ expect(wrapper.emitted("update-field")?.at(-1)).toEqual([{ key: "theme", value: "dark" }]);
+ expect(wrapper.emitted("theme-change")).toHaveLength(1);
+ expect(config.theme).toBe("light");
+ await wrapper.findComponent({ name: "Toggle" }).vm.$emit("update:modelValue", false);
+ expect(wrapper.emitted("update-field")?.at(-1)).toEqual([{ key: "ui_glass_enabled", value: false }]);
+ expect(wrapper.emitted("ui-glass-enabled-change")).toHaveLength(1);
+ });
+});
+
+describe("BatterySettingsSection", () => {
+ it("emits patch and apply events for battery controls", async () => {
+ const batterySaver = {
+ enabled: false,
+ disableVisualiserDiscovery: false,
+ hideOfflineInterfaces: false,
+ maxVisualiserInterfaces: 0,
+ visualiserReloadSeconds: 0,
+ disableVisualiserLiveLayout: false,
+ reduceBackgroundPolling: false,
+ backgroundPollMultiplier: 2,
+ reduceInterfacesDiscovery: false,
+ interfacesStatsPollSeconds: 5,
+ interfacesDiscoveryPollSeconds: 30,
+ applyInterfaceBitrateLimits: false,
+ interfaceBitrateLimits: {},
+ };
+ const wrapper = mount(BatterySettingsSection, {
+ props: {
+ visible: true,
+ batterySaver,
+ batteryInterfaceRows: [],
+ batteryBitrateBusy: false,
+ },
+ global: {
+ mocks: { $t: (key) => key },
+ },
+ });
+ expect(wrapper.text()).toContain("settings.battery.title");
+ const toggles = wrapper.findAllComponents({ name: "Toggle" });
+ expect(toggles.length).toBeGreaterThan(0);
+ await toggles[0].vm.$emit("update:modelValue", true);
+ expect(wrapper.emitted("enabled-change")).toEqual([[true]]);
+ });
+});
+
+describe("VisualiserSettingsSection", () => {
+ it("emits renderer and visibility changes", async () => {
+ const wrapper = mount(VisualiserSettingsSection, {
+ props: {
+ visible: true,
+ renderer: "auto",
+ showDisabledInterfaces: false,
+ showDiscoveredInterfaces: true,
+ },
+ global: {
+ mocks: { $t: (key) => key },
+ },
+ });
+ expect(wrapper.text()).toContain("visualiser.title");
+ await wrapper.find("select").setValue("webgl");
+ expect(wrapper.emitted("renderer-change")?.at(-1)).toEqual(["webgl"]);
+ const toggles = wrapper.findAllComponents({ name: "Toggle" });
+ await toggles[0].vm.$emit("update:modelValue", true);
+ expect(wrapper.emitted("show-disabled-change")).toEqual([[true]]);
+ });
+});
+
+describe("BlockedSettingsSection", () => {
+ it("renders banished management link", () => {
+ const wrapper = mount(BlockedSettingsSection, {
+ props: { visible: true },
+ global: {
+ stubs: {
+ RouterLink: {
+ props: ["to"],
+ template: '<a :href="to.name"><slot /></a>',
+ },
+ },
+ },
+ });
+ expect(wrapper.text()).toContain("Banished");
+ expect(wrapper.text()).toContain("Manage Banished");
+ });
+});
+
+describe("AndroidSettingsSection", () => {
+ it("emits privacy updates and share-apk", async () => {
+ const wrapper = mount(AndroidSettingsSection, {
+ props: {
+ visible: true,
+ androidShellPrivacy: {
+ blockScreenshots: false,
+ clearClipboardOnBackground: false,
+ },
+ },
+ global: {
+ mocks: { $t: (key) => key },
+ },
+ });
+ expect(wrapper.text()).toContain("settings.android_privacy_heading");
+ const checkboxes = wrapper.findAll('input[type="checkbox"]');
+ expect(checkboxes).toHaveLength(2);
+ await checkboxes[0].setValue(true);
+ await checkboxes[1].setValue(true);
+ expect(wrapper.emitted("update:blockScreenshots")).toEqual([[true]]);
+ expect(wrapper.emitted("update:clearClipboardOnBackground")).toEqual([[true]]);
+ await wrapper.find("button").trigger("click");
+ expect(wrapper.emitted("share-apk")).toHaveLength(1);
+ });
+});
+
+describe("ArchiverSettingsSection", () => {
+ it("emits enabled-change, config-change, and flush", async () => {
+ const wrapper = mount(ArchiverSettingsSection, {
+ props: {
+ visible: true,
+ config: {
+ page_archiver_enabled: false,
+ page_archiver_max_versions: 5,
+ archives_max_storage_gb: 2,
+ },
+ },
+ });
+ expect(wrapper.text()).toContain("Page Archiver");
+ await wrapper.findComponent({ name: "Toggle" }).vm.$emit("update:modelValue", true);
+ expect(wrapper.emitted("enabled-change")).toEqual([[true]]);
+ const numberInputs = wrapper.findAll('input[type="number"]');
+ await numberInputs[0].setValue(8);
+ expect(wrapper.emitted("config-change")?.at(-1)).toEqual([{ page_archiver_max_versions: 8 }]);
+ await wrapper.find("button").trigger("click");
+ expect(wrapper.emitted("flush")).toHaveLength(1);
+ });
+});
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────